Document Browser Re-design replaced MacOS' Swift version and unifies all 3 platforms under one implementation.
This commit is contained in:
parent
07550b5c31
commit
21c2aa8e95
|
|
@ -269,6 +269,23 @@ impl App {
|
||||||
use iced_wgpu::core::keyboard;
|
use iced_wgpu::core::keyboard;
|
||||||
use iced_wgpu::core::Event as IcedEvent;
|
use iced_wgpu::core::Event as IcedEvent;
|
||||||
let pressed = event.state == ElementState::Pressed;
|
let pressed = event.state == ElementState::Pressed;
|
||||||
|
|
||||||
|
if pressed {
|
||||||
|
if let Some(action) = match_shortcut(self.modifiers, &event.logical_key) {
|
||||||
|
let msg = match action {
|
||||||
|
MenuAction::ZoomIn => Some(browser::BrowserMessage::ScaleUp),
|
||||||
|
MenuAction::ZoomOut => Some(browser::BrowserMessage::ScaleDown),
|
||||||
|
MenuAction::ZoomReset => Some(browser::BrowserMessage::ScaleReset),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(msg) = msg {
|
||||||
|
handle.state.update(msg);
|
||||||
|
handle.needs_redraw = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let modifiers = decode_winit_modifiers(self.modifiers);
|
let modifiers = decode_winit_modifiers(self.modifiers);
|
||||||
let key = winit_key_to_iced(&event.logical_key);
|
let key = winit_key_to_iced(&event.logical_key);
|
||||||
let text = event.text.as_ref().map(|s| iced_wgpu::core::SmolStr::new(s.as_str()));
|
let text = event.text.as_ref().map(|s| iced_wgpu::core::SmolStr::new(s.as_str()));
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
||||||
observeDocumentTitle()
|
observeDocumentTitle()
|
||||||
|
|
||||||
observeDocumentText()
|
observeDocumentText()
|
||||||
|
wireLoadedTextSync()
|
||||||
syncThemeToViewport()
|
syncThemeToViewport()
|
||||||
syncGutterPrefsToViewport()
|
syncGutterPrefsToViewport()
|
||||||
syncSettingsToViewport()
|
syncSettingsToViewport()
|
||||||
|
|
@ -731,11 +732,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func zoomIn() {
|
@objc private func zoomIn() {
|
||||||
|
if let browser = DocumentBrowserController.shared, browser.isKeyWindow {
|
||||||
|
browser.sendCommand(7)
|
||||||
|
return
|
||||||
|
}
|
||||||
ConfigManager.shared.zoomLevel += 1
|
ConfigManager.shared.zoomLevel += 1
|
||||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func zoomOut() {
|
@objc private func zoomOut() {
|
||||||
|
if let browser = DocumentBrowserController.shared, browser.isKeyWindow {
|
||||||
|
browser.sendCommand(8)
|
||||||
|
return
|
||||||
|
}
|
||||||
let current = ConfigManager.shared.zoomLevel
|
let current = ConfigManager.shared.zoomLevel
|
||||||
if 11 + current > 8 {
|
if 11 + current > 8 {
|
||||||
ConfigManager.shared.zoomLevel -= 1
|
ConfigManager.shared.zoomLevel -= 1
|
||||||
|
|
@ -744,6 +753,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func zoomReset() {
|
@objc private func zoomReset() {
|
||||||
|
if let browser = DocumentBrowserController.shared, browser.isKeyWindow {
|
||||||
|
browser.sendCommand(9)
|
||||||
|
return
|
||||||
|
}
|
||||||
ConfigManager.shared.zoomLevel = 0
|
ConfigManager.shared.zoomLevel = 0
|
||||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
}
|
}
|
||||||
|
|
@ -807,6 +820,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// pushes loaded note text into the viewport synchronously so the autosave timer and quit handler can never observe stale viewport state across a note swap.
|
||||||
|
private func wireLoadedTextSync() {
|
||||||
|
appState.onLoadedTextChanged = { [weak self] text in
|
||||||
|
guard let self = self, let vp = self.viewport else { return }
|
||||||
|
if vp.getText() != text {
|
||||||
|
vp.setText(text)
|
||||||
|
}
|
||||||
|
self.lastAutosavedHash = text.hashValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func syncTextFromViewport() {
|
private func syncTextFromViewport() {
|
||||||
guard let w = window, let vp = w.contentView as? IcedViewportView else { return }
|
guard let w = window, let vp = w.contentView as? IcedViewportView else { return }
|
||||||
let text = vp.getText()
|
let text = vp.getText()
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,8 @@ class AppState: ObservableObject {
|
||||||
private var autoSaveDirty = false
|
private var autoSaveDirty = false
|
||||||
private var autoSaveCoolingDown = false
|
private var autoSaveCoolingDown = false
|
||||||
private let autoSaveQueue = DispatchQueue(label: "com.acord.autosave")
|
private let autoSaveQueue = DispatchQueue(label: "com.acord.autosave")
|
||||||
|
/// fires synchronously after a load/new note swap so the host shell can push the new text into the viewport before the autosave timer reads stale viewport state.
|
||||||
|
var onLoadedTextChanged: ((String) -> Void)?
|
||||||
/// Per-note autosave file path, established on the first write and never
|
/// Per-note autosave file path, established on the first write and never
|
||||||
/// changed for the rest of the session. Stops the title-derived filename
|
/// changed for the rest of the session. Stops the title-derived filename
|
||||||
/// from re-deriving on every keystroke and littering the notes directory
|
/// from re-deriving on every keystroke and littering the notes directory
|
||||||
|
|
@ -314,6 +316,7 @@ class AppState: ObservableObject {
|
||||||
currentFileURL = nil
|
currentFileURL = nil
|
||||||
currentFileFormat = .markdown
|
currentFileFormat = .markdown
|
||||||
refreshNoteList()
|
refreshNoteList()
|
||||||
|
onLoadedTextChanged?(documentText)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectNote(_ id: UUID, extend: Bool = false, range: Bool = false) {
|
func selectNote(_ id: UUID, extend: Bool = false, range: Bool = false) {
|
||||||
|
|
@ -346,6 +349,7 @@ class AppState: ObservableObject {
|
||||||
documentText = bridge.getText(id)
|
documentText = bridge.getText(id)
|
||||||
modified = false
|
modified = false
|
||||||
evaluate()
|
evaluate()
|
||||||
|
onLoadedTextChanged?(documentText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -392,6 +396,15 @@ class AppState: ObservableObject {
|
||||||
func loadNoteFromFile(_ url: URL) {
|
func loadNoteFromFile(_ url: URL) {
|
||||||
let format = FileFormat.from(filename: url.lastPathComponent)
|
let format = FileFormat.from(filename: url.lastPathComponent)
|
||||||
if let (id, text) = bridge.loadNote(path: url.path) {
|
if let (id, text) = bridge.loadNote(path: url.path) {
|
||||||
|
// pin the autosave path before touching documentText so the didSet
|
||||||
|
// autosave path resolution lands on the actual file rather than a
|
||||||
|
// title-derived sibling.
|
||||||
|
let dir = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
|
||||||
|
.standardizedFileURL
|
||||||
|
let parent = url.deletingLastPathComponent().standardizedFileURL
|
||||||
|
if format.isMarkdown && parent == dir {
|
||||||
|
autoSavePaths[id] = url
|
||||||
|
}
|
||||||
currentNoteID = id
|
currentNoteID = id
|
||||||
currentFileURL = url
|
currentFileURL = url
|
||||||
currentFileFormat = format
|
currentFileFormat = format
|
||||||
|
|
@ -400,19 +413,11 @@ class AppState: ObservableObject {
|
||||||
} else {
|
} else {
|
||||||
documentText = text
|
documentText = text
|
||||||
}
|
}
|
||||||
// Lock the autosave path to the loaded file when it lives in the
|
|
||||||
// notes dir. Outside that dir, the user picked their own path —
|
|
||||||
// we won't shadow it with an autosave duplicate.
|
|
||||||
let dir = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory)
|
|
||||||
.standardizedFileURL
|
|
||||||
let parent = url.deletingLastPathComponent().standardizedFileURL
|
|
||||||
if format.isMarkdown && parent == dir {
|
|
||||||
autoSavePaths[id] = url
|
|
||||||
}
|
|
||||||
modified = false
|
modified = false
|
||||||
let _ = bridge.cacheSave(id)
|
let _ = bridge.cacheSave(id)
|
||||||
evaluate()
|
evaluate()
|
||||||
refreshNoteList()
|
refreshNoteList()
|
||||||
|
onLoadedTextChanged?(documentText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,4 +45,12 @@ class DocumentBrowserController {
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// true while the browser window is the focused window.
|
||||||
|
var isKeyWindow: Bool { window.isKeyWindow }
|
||||||
|
|
||||||
|
/// forwards a numeric command to the embedded browser view.
|
||||||
|
func sendCommand(_ command: UInt32) {
|
||||||
|
view.sendCommand(command)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,12 @@ class IcedBrowserView: NSView {
|
||||||
browser_refresh(h)
|
browser_refresh(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// forwards a numeric command to the browser FFI.
|
||||||
|
func sendCommand(_ command: UInt32) {
|
||||||
|
guard let h = browserHandle else { return }
|
||||||
|
browser_send_command(h, command)
|
||||||
|
}
|
||||||
|
|
||||||
override func setFrameSize(_ newSize: NSSize) {
|
override func setFrameSize(_ newSize: NSSize) {
|
||||||
super.setFrameSize(newSize)
|
super.setFrameSize(newSize)
|
||||||
guard let h = browserHandle else { return }
|
guard let h = browserHandle else { return }
|
||||||
|
|
@ -122,6 +128,19 @@ class IcedBrowserView: NSView {
|
||||||
browser_mouse_event(h, Float(pt.x), Float(pt.y), 255, false)
|
browser_mouse_event(h, Float(pt.x), Float(pt.y), 255, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func rightMouseDown(with event: NSEvent) {
|
||||||
|
window?.makeFirstResponder(self)
|
||||||
|
guard let h = browserHandle else { return }
|
||||||
|
let pt = convert(event.locationInWindow, from: nil)
|
||||||
|
browser_mouse_event(h, Float(pt.x), Float(pt.y), 1, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func rightMouseUp(with event: NSEvent) {
|
||||||
|
guard let h = browserHandle else { return }
|
||||||
|
let pt = convert(event.locationInWindow, from: nil)
|
||||||
|
browser_mouse_event(h, Float(pt.x), Float(pt.y), 1, false)
|
||||||
|
}
|
||||||
|
|
||||||
override func scrollWheel(with event: NSEvent) {
|
override func scrollWheel(with event: NSEvent) {
|
||||||
guard let h = browserHandle else { return }
|
guard let h = browserHandle else { return }
|
||||||
browser_scroll_event(h, Float(event.scrollingDeltaX), Float(event.scrollingDeltaY))
|
browser_scroll_event(h, Float(event.scrollingDeltaX), Float(event.scrollingDeltaY))
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,11 @@ char *browser_take_pending_open(struct BrowserHandle *handle);
|
||||||
|
|
||||||
void browser_refresh(struct BrowserHandle *handle);
|
void browser_refresh(struct BrowserHandle *handle);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dispatches a numeric zoom command into the browser's scale state.
|
||||||
|
*/
|
||||||
|
void browser_send_command(struct BrowserHandle *handle, uint32_t command);
|
||||||
|
|
||||||
uint32_t viewport_render_mode(struct ViewportHandle *handle);
|
uint32_t viewport_render_mode(struct ViewportHandle *handle);
|
||||||
|
|
||||||
#endif /* ACORD_VIEWPORT_H */
|
#endif /* ACORD_VIEWPORT_H */
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,19 @@ pub fn render(handle: &mut BrowserHandle) {
|
||||||
.events
|
.events
|
||||||
.push(Event::Window(window::Event::RedrawRequested(iced_wgpu::core::time::Instant::now())));
|
.push(Event::Window(window::Event::RedrawRequested(iced_wgpu::core::time::Instant::now())));
|
||||||
|
|
||||||
// First UI build receives input events and emits messages.
|
// pre-scans events so the modifier and cursor state are visible to message handlers fired this frame.
|
||||||
|
for ev in &handle.events {
|
||||||
|
match ev {
|
||||||
|
Event::Keyboard(iced_wgpu::core::keyboard::Event::ModifiersChanged(m)) => {
|
||||||
|
handle.state.current_modifiers = *m;
|
||||||
|
}
|
||||||
|
Event::Mouse(iced_wgpu::core::mouse::Event::CursorMoved { position }) => {
|
||||||
|
handle.state.cursor_pos = *position;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let cache = std::mem::take(&mut handle.cache);
|
let cache = std::mem::take(&mut handle.cache);
|
||||||
let mut ui = UserInterface::build(
|
let mut ui = UserInterface::build(
|
||||||
ui::view(&handle.state),
|
ui::view(&handle.state),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
pub mod preview;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod handle;
|
pub mod handle;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use super::preview::{highlight_preview, PreviewLine};
|
||||||
|
|
||||||
const SUPPORTED_EXTS: &[&str] = &["md", "txt", "markdown", "mdown"];
|
const SUPPORTED_EXTS: &[&str] = &["md", "txt", "markdown", "mdown"];
|
||||||
const PREVIEW_LINES: usize = 20;
|
const PREVIEW_LINES: usize = 32;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum BrowserItemKind {
|
pub enum BrowserItemKind {
|
||||||
|
|
@ -17,6 +19,7 @@ pub struct BrowserItem {
|
||||||
pub kind: BrowserItemKind,
|
pub kind: BrowserItemKind,
|
||||||
pub modified: SystemTime,
|
pub modified: SystemTime,
|
||||||
pub preview: String,
|
pub preview: String,
|
||||||
|
pub preview_lines: Vec<PreviewLine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Folders first, then files; both in date-modified descending order.
|
/// Folders first, then files; both in date-modified descending order.
|
||||||
|
|
@ -41,6 +44,7 @@ pub fn scan_directory(dir: &Path) -> Vec<BrowserItem> {
|
||||||
kind: BrowserItemKind::Folder,
|
kind: BrowserItemKind::Folder,
|
||||||
modified,
|
modified,
|
||||||
preview: folder_summary(&path),
|
preview: folder_summary(&path),
|
||||||
|
preview_lines: Vec::new(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let ext = path
|
let ext = path
|
||||||
|
|
@ -56,12 +60,16 @@ pub fn scan_directory(dir: &Path) -> Vec<BrowserItem> {
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.unwrap_or(name);
|
.unwrap_or(name);
|
||||||
|
|
||||||
|
let preview = file_preview(&path);
|
||||||
|
let preview_lines = highlight_preview(&preview);
|
||||||
|
|
||||||
files.push(BrowserItem {
|
files.push(BrowserItem {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
name: display,
|
name: display,
|
||||||
kind: BrowserItemKind::File,
|
kind: BrowserItemKind::File,
|
||||||
modified,
|
modified,
|
||||||
preview: file_preview(&path),
|
preview,
|
||||||
|
preview_lines,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -208,6 +216,17 @@ pub fn create_folder(parent: &Path) -> std::io::Result<PathBuf> {
|
||||||
Ok(dest)
|
Ok(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a fresh folder next to `items` and moves each one inside.
|
||||||
|
/// Items already living in the destination are skipped to avoid same-name self-moves.
|
||||||
|
pub fn create_folder_with_items(parent: &Path, items: &[PathBuf]) -> std::io::Result<PathBuf> {
|
||||||
|
let folder = create_folder(parent)?;
|
||||||
|
for item in items {
|
||||||
|
if item.parent() == Some(folder.as_path()) { continue; }
|
||||||
|
let _ = move_into(item, &folder);
|
||||||
|
}
|
||||||
|
Ok(folder)
|
||||||
|
}
|
||||||
|
|
||||||
/// Sends the path to the OS trash; falls back to permanent delete on platforms without trash support.
|
/// Sends the path to the OS trash; falls back to permanent delete on platforms without trash support.
|
||||||
pub fn trash(item_path: &Path) -> std::io::Result<()> {
|
pub fn trash(item_path: &Path) -> std::io::Result<()> {
|
||||||
match trash_crate_remove(item_path) {
|
match trash_crate_remove(item_path) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use iced_wgpu::core::text::highlighter::Highlighter;
|
||||||
|
|
||||||
|
use crate::syntax::{SyntaxHighlight, SyntaxHighlighter, SyntaxSettings};
|
||||||
|
|
||||||
|
/// a single highlighted preview line with byte-range spans and a markdown heading level.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PreviewLine {
|
||||||
|
pub text: String,
|
||||||
|
pub spans: Vec<(Range<usize>, u8)>,
|
||||||
|
pub heading: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// highlights source line-by-line with a fresh per-preview user-ident rainbow.
|
||||||
|
pub fn highlight_preview(source: &str) -> Vec<PreviewLine> {
|
||||||
|
let settings = SyntaxSettings {
|
||||||
|
lang: "rust".to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
};
|
||||||
|
let mut highlighter = SyntaxHighlighter::new(&settings);
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for line in source.split('\n') {
|
||||||
|
let spans: Vec<(Range<usize>, u8)> = highlighter
|
||||||
|
.highlight_line(line)
|
||||||
|
.map(|(range, SyntaxHighlight { kind })| (range, kind))
|
||||||
|
.collect();
|
||||||
|
let heading = parse_heading_level(line);
|
||||||
|
out.push(PreviewLine {
|
||||||
|
text: line.to_string(),
|
||||||
|
spans,
|
||||||
|
heading,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns the markdown heading level of the line, capped at 3, or none.
|
||||||
|
fn parse_heading_level(line: &str) -> Option<u8> {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
let bytes = trimmed.as_bytes();
|
||||||
|
if bytes.is_empty() || bytes[0] != b'#' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut level = 0usize;
|
||||||
|
while level < bytes.len() && bytes[level] == b'#' {
|
||||||
|
level += 1;
|
||||||
|
}
|
||||||
|
if level == 0 || level > 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if level < bytes.len() && bytes[level] == b' ' {
|
||||||
|
Some(level as u8)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,36 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use iced_wgpu::core::keyboard::Modifiers;
|
||||||
|
use iced_wgpu::core::Point;
|
||||||
|
|
||||||
use super::model::{self, BrowserItem, BrowserItemKind};
|
use super::model::{self, BrowserItem, BrowserItemKind};
|
||||||
|
|
||||||
pub struct BrowserState {
|
pub struct BrowserState {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
pub current: PathBuf,
|
pub current: PathBuf,
|
||||||
pub items: Vec<BrowserItem>,
|
pub items: Vec<BrowserItem>,
|
||||||
pub selected: Option<PathBuf>,
|
pub selected: HashSet<PathBuf>,
|
||||||
|
pub selection_anchor: Option<PathBuf>,
|
||||||
pub scale: f32,
|
pub scale: f32,
|
||||||
pub renaming: Option<PathBuf>,
|
pub renaming: Option<PathBuf>,
|
||||||
pub rename_text: String,
|
pub rename_text: String,
|
||||||
/// Set when an item should be opened; the host shell drains this each frame.
|
/// holds the next path the host shell should open; drained each frame.
|
||||||
pub pending_open: Option<PathBuf>,
|
pub pending_open: Option<PathBuf>,
|
||||||
pub context_menu: Option<ContextMenu>,
|
pub context_menu: Option<ContextMenu>,
|
||||||
|
pub current_modifiers: Modifiers,
|
||||||
|
pub cursor_pos: Point,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ContextMenu {
|
pub struct ContextMenu {
|
||||||
pub anchor: iced_wgpu::core::Point,
|
pub anchor: Point,
|
||||||
|
/// None when the right-click landed between cards.
|
||||||
|
pub target: Option<ContextTarget>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ContextTarget {
|
||||||
pub item_path: PathBuf,
|
pub item_path: PathBuf,
|
||||||
pub is_file: bool,
|
pub is_file: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -34,11 +47,18 @@ pub enum BrowserMessage {
|
||||||
Duplicate(PathBuf),
|
Duplicate(PathBuf),
|
||||||
Trash(PathBuf),
|
Trash(PathBuf),
|
||||||
NewFolder,
|
NewFolder,
|
||||||
|
NewFolderWithSelection,
|
||||||
ScaleUp,
|
ScaleUp,
|
||||||
ScaleDown,
|
ScaleDown,
|
||||||
|
ScaleReset,
|
||||||
Refresh,
|
Refresh,
|
||||||
ShowContextMenu { anchor: iced_wgpu::core::Point, path: PathBuf, is_file: bool },
|
ShowContextMenu { path: PathBuf, is_file: bool },
|
||||||
|
ShowEmptyContextMenu,
|
||||||
HideContextMenu,
|
HideContextMenu,
|
||||||
|
ContextOpen,
|
||||||
|
ContextRename,
|
||||||
|
ContextDuplicate,
|
||||||
|
ContextTrash,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrowserState {
|
impl BrowserState {
|
||||||
|
|
@ -49,24 +69,29 @@ impl BrowserState {
|
||||||
root,
|
root,
|
||||||
current,
|
current,
|
||||||
items,
|
items,
|
||||||
selected: None,
|
selected: HashSet::new(),
|
||||||
|
selection_anchor: None,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
renaming: None,
|
renaming: None,
|
||||||
rename_text: String::new(),
|
rename_text: String::new(),
|
||||||
pending_open: None,
|
pending_open: None,
|
||||||
context_menu: None,
|
context_menu: None,
|
||||||
|
current_modifiers: Modifiers::empty(),
|
||||||
|
cursor_pos: Point::ORIGIN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh(&mut self) {
|
pub fn refresh(&mut self) {
|
||||||
self.items = model::scan_directory(&self.current);
|
self.items = model::scan_directory(&self.current);
|
||||||
|
self.prune_selection();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, msg: BrowserMessage) {
|
pub fn update(&mut self, msg: BrowserMessage) {
|
||||||
match msg {
|
match msg {
|
||||||
BrowserMessage::NavigateTo(path) => {
|
BrowserMessage::NavigateTo(path) => {
|
||||||
self.current = path;
|
self.current = path;
|
||||||
self.selected = None;
|
self.selected.clear();
|
||||||
|
self.selection_anchor = None;
|
||||||
self.renaming = None;
|
self.renaming = None;
|
||||||
self.context_menu = None;
|
self.context_menu = None;
|
||||||
self.refresh();
|
self.refresh();
|
||||||
|
|
@ -76,7 +101,7 @@ impl BrowserState {
|
||||||
self.context_menu = None;
|
self.context_menu = None;
|
||||||
}
|
}
|
||||||
BrowserMessage::Select(path) => {
|
BrowserMessage::Select(path) => {
|
||||||
self.selected = Some(path);
|
self.apply_selection(path);
|
||||||
self.context_menu = None;
|
self.context_menu = None;
|
||||||
}
|
}
|
||||||
BrowserMessage::StartRename(path) => {
|
BrowserMessage::StartRename(path) => {
|
||||||
|
|
@ -111,31 +136,103 @@ impl BrowserState {
|
||||||
}
|
}
|
||||||
BrowserMessage::Trash(path) => {
|
BrowserMessage::Trash(path) => {
|
||||||
let _ = model::trash(&path);
|
let _ = model::trash(&path);
|
||||||
if self.selected.as_deref() == Some(&path) {
|
self.selected.remove(&path);
|
||||||
self.selected = None;
|
if self.selection_anchor.as_deref() == Some(&path) {
|
||||||
|
self.selection_anchor = None;
|
||||||
}
|
}
|
||||||
self.context_menu = None;
|
self.context_menu = None;
|
||||||
self.refresh();
|
self.refresh();
|
||||||
}
|
}
|
||||||
BrowserMessage::NewFolder => {
|
BrowserMessage::NewFolder => {
|
||||||
let _ = model::create_folder(&self.current);
|
self.context_menu = None;
|
||||||
self.refresh();
|
if let Ok(folder) = model::create_folder(&self.current) {
|
||||||
|
self.refresh();
|
||||||
|
self.start_renaming(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BrowserMessage::NewFolderWithSelection => {
|
||||||
|
self.context_menu = None;
|
||||||
|
let items: Vec<PathBuf> = self.selected.iter().cloned().collect();
|
||||||
|
if items.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(folder) = model::create_folder_with_items(&self.current, &items) {
|
||||||
|
self.selected.clear();
|
||||||
|
self.selection_anchor = None;
|
||||||
|
self.refresh();
|
||||||
|
self.start_renaming(folder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
BrowserMessage::ScaleUp => {
|
BrowserMessage::ScaleUp => {
|
||||||
self.scale = (self.scale + 0.1).min(3.0);
|
self.scale = (self.scale * 14.0 / 13.0).min(3.0);
|
||||||
}
|
}
|
||||||
BrowserMessage::ScaleDown => {
|
BrowserMessage::ScaleDown => {
|
||||||
self.scale = (self.scale - 0.1).max(0.4);
|
self.scale = (self.scale * 13.0 / 14.0).max(0.4);
|
||||||
|
}
|
||||||
|
BrowserMessage::ScaleReset => {
|
||||||
|
self.scale = 1.0;
|
||||||
}
|
}
|
||||||
BrowserMessage::Refresh => {
|
BrowserMessage::Refresh => {
|
||||||
self.refresh();
|
self.refresh();
|
||||||
}
|
}
|
||||||
BrowserMessage::ShowContextMenu { anchor, path, is_file } => {
|
BrowserMessage::ShowContextMenu { path, is_file } => {
|
||||||
self.context_menu = Some(ContextMenu { anchor, item_path: path, is_file });
|
self.context_menu = Some(ContextMenu {
|
||||||
|
anchor: self.cursor_pos,
|
||||||
|
target: Some(ContextTarget { item_path: path, is_file }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
BrowserMessage::ShowEmptyContextMenu => {
|
||||||
|
self.context_menu = Some(ContextMenu {
|
||||||
|
anchor: self.cursor_pos,
|
||||||
|
target: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
BrowserMessage::HideContextMenu => {
|
BrowserMessage::HideContextMenu => {
|
||||||
self.context_menu = None;
|
self.context_menu = None;
|
||||||
}
|
}
|
||||||
|
BrowserMessage::ContextOpen => {
|
||||||
|
self.context_menu = None;
|
||||||
|
if let Some(path) = self.single_selected() {
|
||||||
|
if path.is_dir() {
|
||||||
|
self.current = path;
|
||||||
|
self.selected.clear();
|
||||||
|
self.selection_anchor = None;
|
||||||
|
self.refresh();
|
||||||
|
} else {
|
||||||
|
self.pending_open = Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BrowserMessage::ContextRename => {
|
||||||
|
self.context_menu = None;
|
||||||
|
if let Some(path) = self.single_selected() {
|
||||||
|
self.start_renaming(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BrowserMessage::ContextDuplicate => {
|
||||||
|
self.context_menu = None;
|
||||||
|
let targets: Vec<PathBuf> = self.selected.iter().cloned().collect();
|
||||||
|
for path in targets {
|
||||||
|
let _ = model::duplicate(&path);
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
BrowserMessage::ContextTrash => {
|
||||||
|
self.context_menu = None;
|
||||||
|
let targets: Vec<PathBuf> = self.selected.iter().cloned().collect();
|
||||||
|
for path in &targets {
|
||||||
|
let _ = model::trash(path);
|
||||||
|
}
|
||||||
|
for path in &targets {
|
||||||
|
self.selected.remove(path);
|
||||||
|
}
|
||||||
|
if let Some(anchor) = &self.selection_anchor {
|
||||||
|
if targets.contains(anchor) {
|
||||||
|
self.selection_anchor = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,10 +249,88 @@ impl BrowserState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_selected(&self, item: &BrowserItem) -> bool {
|
pub fn is_selected(&self, item: &BrowserItem) -> bool {
|
||||||
self.selected.as_deref() == Some(&item.path)
|
self.selected.contains(&item.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn item_kind_is_file(item: &BrowserItem) -> bool {
|
pub fn item_kind_is_file(item: &BrowserItem) -> bool {
|
||||||
item.kind == BrowserItemKind::File
|
item.kind == BrowserItemKind::File
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when a context menu was opened on an item that's part of the live selection.
|
||||||
|
pub fn context_acts_on_selection(&self) -> bool {
|
||||||
|
match self.context_menu.as_ref().and_then(|m| m.target.as_ref()) {
|
||||||
|
Some(t) => self.selected.contains(&t.item_path),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the lone selected path when selection size is exactly one.
|
||||||
|
pub fn single_selected(&self) -> Option<PathBuf> {
|
||||||
|
if self.selected.len() == 1 {
|
||||||
|
self.selected.iter().next().cloned()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// applies command/shift/plain selection rules to the clicked path.
|
||||||
|
fn apply_selection(&mut self, path: PathBuf) {
|
||||||
|
let mods = self.current_modifiers;
|
||||||
|
if mods.command() {
|
||||||
|
if !self.selected.insert(path.clone()) {
|
||||||
|
self.selected.remove(&path);
|
||||||
|
}
|
||||||
|
self.selection_anchor = Some(path);
|
||||||
|
} else if mods.shift() {
|
||||||
|
self.select_range_from_anchor(&path);
|
||||||
|
} else {
|
||||||
|
self.selected.clear();
|
||||||
|
self.selected.insert(path.clone());
|
||||||
|
self.selection_anchor = Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// extends the selection from the current anchor to the given path, replacing existing selection.
|
||||||
|
fn select_range_from_anchor(&mut self, path: &PathBuf) {
|
||||||
|
let Some(anchor) = self.selection_anchor.clone() else {
|
||||||
|
self.selected.clear();
|
||||||
|
self.selected.insert(path.clone());
|
||||||
|
self.selection_anchor = Some(path.clone());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let a = self.items.iter().position(|i| i.path == anchor);
|
||||||
|
let b = self.items.iter().position(|i| i.path == *path);
|
||||||
|
let (Some(a), Some(b)) = (a, b) else {
|
||||||
|
self.selected.clear();
|
||||||
|
self.selected.insert(path.clone());
|
||||||
|
self.selection_anchor = Some(path.clone());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
|
||||||
|
self.selected.clear();
|
||||||
|
for i in lo..=hi {
|
||||||
|
self.selected.insert(self.items[i].path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_renaming(&mut self, path: PathBuf) {
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.rename_text = stem;
|
||||||
|
self.renaming = Some(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// drops selection entries that no longer exist after a refresh.
|
||||||
|
fn prune_selection(&mut self) {
|
||||||
|
let live: HashSet<PathBuf> = self.items.iter().map(|i| i.path.clone()).collect();
|
||||||
|
self.selected.retain(|p| live.contains(p));
|
||||||
|
if let Some(anchor) = &self.selection_anchor {
|
||||||
|
if !live.contains(anchor) {
|
||||||
|
self.selection_anchor = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,53 @@
|
||||||
use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Theme};
|
use iced_wgpu::core::text::{Span as TextSpan, Wrapping};
|
||||||
use iced_widget::{button, column, container, mouse_area, row, scrollable, text, text_input, Space};
|
use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Pixels, Point, Size, Theme};
|
||||||
|
use iced_widget::text::Rich;
|
||||||
|
use iced_widget::{
|
||||||
|
button, column, container, mouse_area, opaque, responsive, row, scrollable, span, stack, text,
|
||||||
|
text_input, Space,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::palette;
|
use crate::palette;
|
||||||
|
use crate::syntax::{highlight_color, highlight_font};
|
||||||
use super::model::{BrowserItem, BrowserItemKind};
|
use super::model::{BrowserItem, BrowserItemKind};
|
||||||
use super::state::{BrowserMessage, BrowserState};
|
use super::preview::PreviewLine;
|
||||||
|
use super::state::{BrowserMessage, BrowserState, ContextMenu};
|
||||||
|
|
||||||
const CARDS_PER_ROW: usize = 3;
|
const TARGET_CARD_W: f32 = 280.0;
|
||||||
const CARD_BASE_W: f32 = 240.0;
|
const MIN_CARD_W: f32 = 180.0;
|
||||||
|
const GAP: f32 = 16.0;
|
||||||
|
const OUTER_PAD: f32 = 16.0;
|
||||||
|
const CARD_PAD: f32 = 10.0;
|
||||||
|
const CARD_ASPECT: f32 = 0.72;
|
||||||
|
|
||||||
pub fn view(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
pub fn view(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
let p = palette::current();
|
let p = palette::current();
|
||||||
|
|
||||||
let body: Element<_, _, _> = if state.items.is_empty() {
|
let body_inner: Element<_, _, _> = if state.items.is_empty() {
|
||||||
empty_state()
|
empty_state()
|
||||||
} else {
|
} else {
|
||||||
scrollable(grid(state)).height(Length::Fill).into()
|
responsive(|size| scrollable(grid(state, size)).height(Length::Fill).into()).into()
|
||||||
};
|
};
|
||||||
|
|
||||||
let main = column![
|
// Captures right-clicks that fall between cards. Cards have their own
|
||||||
|
// on_right_press, so this only fires on the gaps and empty regions.
|
||||||
|
let body: Element<_, _, _> = mouse_area(body_inner)
|
||||||
|
.on_right_press(BrowserMessage::ShowEmptyContextMenu)
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let main: Element<_, _, _> = column![
|
||||||
breadcrumb(state),
|
breadcrumb(state),
|
||||||
rule(p.surface1),
|
rule(p.surface1),
|
||||||
body,
|
body,
|
||||||
]
|
]
|
||||||
.height(Length::Fill);
|
.height(Length::Fill)
|
||||||
|
.into();
|
||||||
|
|
||||||
container(main)
|
let layered: Element<_, _, _> = match state.context_menu.as_ref() {
|
||||||
|
Some(menu) => stack![main, context_menu_overlay(state, menu)].into(),
|
||||||
|
None => main,
|
||||||
|
};
|
||||||
|
|
||||||
|
container(layered)
|
||||||
.style(move |_t: &Theme| container::Style {
|
.style(move |_t: &Theme| container::Style {
|
||||||
background: Some(Background::Color(p.base)),
|
background: Some(Background::Color(p.base)),
|
||||||
border: Border::default(),
|
border: Border::default(),
|
||||||
|
|
@ -43,6 +66,7 @@ fn breadcrumb(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_w
|
||||||
let last_idx = segments.len().saturating_sub(1);
|
let last_idx = segments.len().saturating_sub(1);
|
||||||
|
|
||||||
let mut row_items: Vec<Element<_, _, _>> = Vec::new();
|
let mut row_items: Vec<Element<_, _, _>> = Vec::new();
|
||||||
|
|
||||||
for (i, (name, path)) in segments.into_iter().enumerate() {
|
for (i, (name, path)) in segments.into_iter().enumerate() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
row_items.push(
|
row_items.push(
|
||||||
|
|
@ -107,98 +131,110 @@ fn empty_state() -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer>
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn grid(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
/// picks the column count whose card-width sits closest to the scale-adjusted target.
|
||||||
let scale = state.scale;
|
fn columns_for_width(avail_w: f32, scale: f32) -> usize {
|
||||||
let mut rows: Vec<Element<_, _, _>> = Vec::new();
|
let target = TARGET_CARD_W * scale;
|
||||||
let chunk_size = CARDS_PER_ROW;
|
let min_w = MIN_CARD_W * scale;
|
||||||
|
let inner = (avail_w - 2.0 * OUTER_PAD).max(0.0);
|
||||||
|
if inner < min_w {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
let mut best = 1usize;
|
||||||
|
let mut best_diff = f32::MAX;
|
||||||
|
for n in 1..=8 {
|
||||||
|
let nf = n as f32;
|
||||||
|
let card_w = (inner - (nf - 1.0) * GAP * scale) / nf;
|
||||||
|
if card_w < min_w {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let diff = (card_w - target).abs();
|
||||||
|
if diff < best_diff {
|
||||||
|
best_diff = diff;
|
||||||
|
best = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
for chunk in state.items.chunks(chunk_size) {
|
/// lays out items as a fill-the-width grid of fixed-aspect cards.
|
||||||
|
fn grid(state: &BrowserState, size: Size) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
|
let scale = state.scale;
|
||||||
|
let cols = columns_for_width(size.width, scale);
|
||||||
|
|
||||||
|
let inner = (size.width - 2.0 * OUTER_PAD).max(0.0);
|
||||||
|
let card_w = ((inner - (cols as f32 - 1.0) * GAP * scale) / cols as f32).max(MIN_CARD_W * scale);
|
||||||
|
let card_h = card_w * CARD_ASPECT;
|
||||||
|
|
||||||
|
let mut rows: Vec<Element<_, _, _>> = Vec::new();
|
||||||
|
for chunk in state.items.chunks(cols) {
|
||||||
let mut row_items: Vec<Element<_, _, _>> = Vec::new();
|
let mut row_items: Vec<Element<_, _, _>> = Vec::new();
|
||||||
for item in chunk {
|
for item in chunk {
|
||||||
row_items.push(card(item, state, scale));
|
row_items.push(card(item, state, scale, card_w, card_h));
|
||||||
}
|
}
|
||||||
// Pad short final row so cards keep their fixed width instead of stretching.
|
while row_items.len() < cols {
|
||||||
while row_items.len() < chunk_size {
|
|
||||||
row_items.push(
|
row_items.push(
|
||||||
Space::new()
|
Space::new()
|
||||||
.width(Length::Fill)
|
.width(Length::Fixed(card_w))
|
||||||
.height(Length::Shrink)
|
.height(Length::Fixed(card_h))
|
||||||
.into()
|
.into()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
rows.push(
|
rows.push(
|
||||||
row(row_items)
|
row(row_items)
|
||||||
.spacing(16.0 * scale)
|
.spacing(GAP * scale)
|
||||||
.into()
|
.into()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
container(
|
container(
|
||||||
column(rows)
|
column(rows)
|
||||||
.spacing(16.0 * scale)
|
.spacing(GAP * scale)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
)
|
)
|
||||||
.padding(16.0 * scale)
|
.padding(OUTER_PAD)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// stacks a kind-specific preview above a title strip inside one click target.
|
||||||
fn card<'a>(
|
fn card<'a>(
|
||||||
item: &'a BrowserItem,
|
item: &'a BrowserItem,
|
||||||
state: &'a BrowserState,
|
state: &'a BrowserState,
|
||||||
scale: f32,
|
scale: f32,
|
||||||
|
card_w: f32,
|
||||||
|
card_h: f32,
|
||||||
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
let p = palette::current();
|
let p = palette::current();
|
||||||
let selected = state.is_selected(item);
|
let selected = state.is_selected(item);
|
||||||
let renaming = state.is_renaming(item);
|
let renaming = state.is_renaming(item);
|
||||||
|
|
||||||
let preview_h = (CARD_BASE_W * scale) * 0.55;
|
let title_size = 12.0 * scale;
|
||||||
let card_w = CARD_BASE_W * scale;
|
let title_h = title_size * 1.4 + 4.0;
|
||||||
|
let preview_h = (card_h - title_h - CARD_PAD * 2.0 - 6.0 * scale).max(0.0);
|
||||||
|
|
||||||
let preview: Element<_, _, _> = match item.kind {
|
let preview: Element<_, _, _> = match item.kind {
|
||||||
BrowserItemKind::Folder => container(
|
BrowserItemKind::Folder => folder_preview(&item.preview, scale, preview_h),
|
||||||
row![
|
BrowserItemKind::File => file_preview(&item.preview_lines, scale, preview_h),
|
||||||
text("\u{1F4C1}").size(24.0 * scale).color(p.blue),
|
|
||||||
text(item.preview.clone()).size(10.0 * scale).color(p.subtext0),
|
|
||||||
]
|
|
||||||
.spacing(8.0 * scale)
|
|
||||||
)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.height(Length::Fixed(preview_h))
|
|
||||||
.padding(8.0 * scale)
|
|
||||||
.style(move |_t: &Theme| container::Style {
|
|
||||||
background: Some(Background::Color(p.mantle)),
|
|
||||||
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: (4.0 * scale).into() },
|
|
||||||
text_color: Some(p.text),
|
|
||||||
shadow: Default::default(),
|
|
||||||
snap: false,
|
|
||||||
})
|
|
||||||
.into(),
|
|
||||||
BrowserItemKind::File => container(
|
|
||||||
text(item.preview.clone()).size(10.0 * scale).color(p.subtext0)
|
|
||||||
)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.height(Length::Fixed(preview_h))
|
|
||||||
.padding(8.0 * scale)
|
|
||||||
.style(move |_t: &Theme| container::Style {
|
|
||||||
background: Some(Background::Color(p.mantle)),
|
|
||||||
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: (4.0 * scale).into() },
|
|
||||||
text_color: Some(p.subtext0),
|
|
||||||
shadow: Default::default(),
|
|
||||||
snap: false,
|
|
||||||
})
|
|
||||||
.into(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let title: Element<_, _, _> = if renaming {
|
let title: Element<_, _, _> = if renaming {
|
||||||
text_input("Name", &state.rename_text)
|
text_input("Name", &state.rename_text)
|
||||||
.on_input(BrowserMessage::UpdateRename)
|
.on_input(BrowserMessage::UpdateRename)
|
||||||
.on_submit(BrowserMessage::CommitRename)
|
.on_submit(BrowserMessage::CommitRename)
|
||||||
.size(12.0 * scale)
|
.size(title_size)
|
||||||
.padding(Padding { top: 2.0, right: 4.0, bottom: 2.0, left: 4.0 })
|
.padding(Padding { top: 2.0, right: 4.0, bottom: 2.0, left: 4.0 })
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
text(item.name.clone()).size(12.0 * scale).color(p.text).into()
|
container(
|
||||||
|
text(item.name.clone())
|
||||||
|
.size(title_size)
|
||||||
|
.color(p.text)
|
||||||
|
.wrapping(Wrapping::None),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fixed(title_h))
|
||||||
|
.clip(true)
|
||||||
|
.into()
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = column![preview, title].spacing(6.0 * scale);
|
let content = column![preview, title].spacing(6.0 * scale);
|
||||||
|
|
@ -208,7 +244,9 @@ fn card<'a>(
|
||||||
|
|
||||||
let body = container(content)
|
let body = container(content)
|
||||||
.width(Length::Fixed(card_w))
|
.width(Length::Fixed(card_w))
|
||||||
.padding(10.0 * scale)
|
.height(Length::Fixed(card_h))
|
||||||
|
.padding(CARD_PAD)
|
||||||
|
.clip(true)
|
||||||
.style(move |_t: &Theme| container::Style {
|
.style(move |_t: &Theme| container::Style {
|
||||||
background: Some(Background::Color(if selected { p.surface1 } else { p.surface0 })),
|
background: Some(Background::Color(if selected { p.surface1 } else { p.surface0 })),
|
||||||
border: Border {
|
border: Border {
|
||||||
|
|
@ -221,17 +259,258 @@ fn card<'a>(
|
||||||
snap: false,
|
snap: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let click_msg = match item.kind {
|
let open_msg = match item.kind {
|
||||||
BrowserItemKind::Folder => BrowserMessage::NavigateTo(item_path.clone()),
|
BrowserItemKind::Folder => BrowserMessage::NavigateTo(item_path.clone()),
|
||||||
BrowserItemKind::File => BrowserMessage::Open(item_path.clone()),
|
BrowserItemKind::File => BrowserMessage::Open(item_path.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
mouse_area(body)
|
mouse_area(body)
|
||||||
.on_press(click_msg)
|
.on_press(BrowserMessage::Select(item_path.clone()))
|
||||||
|
.on_double_click(open_msg)
|
||||||
.on_right_press(BrowserMessage::ShowContextMenu {
|
.on_right_press(BrowserMessage::ShowContextMenu {
|
||||||
anchor: iced_wgpu::core::Point::new(0.0, 0.0),
|
|
||||||
path: item_path,
|
path: item_path,
|
||||||
is_file,
|
is_file,
|
||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// renders a folder icon and item-count summary inside the card's preview slot.
|
||||||
|
fn folder_preview(
|
||||||
|
summary: &str,
|
||||||
|
scale: f32,
|
||||||
|
preview_h: f32,
|
||||||
|
) -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
|
let p = palette::current();
|
||||||
|
container(
|
||||||
|
row![
|
||||||
|
text("\u{1F4C1}").size(24.0 * scale).color(p.blue),
|
||||||
|
text(summary.to_string()).size(10.0 * scale).color(p.subtext0),
|
||||||
|
]
|
||||||
|
.spacing(8.0 * scale)
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fixed(preview_h))
|
||||||
|
.padding(8.0 * scale)
|
||||||
|
.style(move |_t: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(p.mantle)),
|
||||||
|
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: (4.0 * scale).into() },
|
||||||
|
text_color: Some(p.text),
|
||||||
|
shadow: Default::default(),
|
||||||
|
snap: false,
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// renders pre-highlighted preview lines as a clipped column of rich-text.
|
||||||
|
fn file_preview<'a>(
|
||||||
|
lines: &'a [PreviewLine],
|
||||||
|
scale: f32,
|
||||||
|
preview_h: f32,
|
||||||
|
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
|
let p = palette::current();
|
||||||
|
let body_size = 9.0 * scale;
|
||||||
|
let line_spacing = 2.0 * scale;
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
return container(text("(empty)").size(body_size).color(p.overlay0))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fixed(preview_h))
|
||||||
|
.padding(8.0 * scale)
|
||||||
|
.style(move |_t: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(p.mantle)),
|
||||||
|
border: Border {
|
||||||
|
color: Color::TRANSPARENT,
|
||||||
|
width: 0.0,
|
||||||
|
radius: (4.0 * scale).into(),
|
||||||
|
},
|
||||||
|
text_color: Some(p.subtext0),
|
||||||
|
shadow: Default::default(),
|
||||||
|
snap: false,
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut col_items: Vec<Element<_, _, _>> = Vec::new();
|
||||||
|
for line in lines {
|
||||||
|
let size = match line.heading {
|
||||||
|
Some(1) => body_size * 1.5,
|
||||||
|
Some(2) => body_size * 1.3,
|
||||||
|
Some(3) => body_size * 1.15,
|
||||||
|
_ => body_size,
|
||||||
|
};
|
||||||
|
col_items.push(preview_line(line, size, p.subtext0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner = column(col_items).spacing(line_spacing);
|
||||||
|
|
||||||
|
container(inner)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fixed(preview_h))
|
||||||
|
.padding(8.0 * scale)
|
||||||
|
.clip(true)
|
||||||
|
.style(move |_t: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(p.mantle)),
|
||||||
|
border: Border {
|
||||||
|
color: Color::TRANSPARENT,
|
||||||
|
width: 0.0,
|
||||||
|
radius: (4.0 * scale).into(),
|
||||||
|
},
|
||||||
|
text_color: Some(p.subtext0),
|
||||||
|
shadow: Default::default(),
|
||||||
|
snap: false,
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// turns one preview line's syntax spans into a rich-text element at the given size.
|
||||||
|
fn preview_line<'a>(
|
||||||
|
line: &'a PreviewLine,
|
||||||
|
size: f32,
|
||||||
|
fallback: Color,
|
||||||
|
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
|
if line.text.is_empty() {
|
||||||
|
return Space::new().width(Length::Shrink).height(Length::Fixed(size * 0.6)).into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut spans: Vec<TextSpan<'a, ()>> = Vec::new();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for (range, kind) in &line.spans {
|
||||||
|
if range.start > cursor {
|
||||||
|
spans.push(plain_span(&line.text[cursor..range.start], fallback));
|
||||||
|
}
|
||||||
|
let slice = &line.text[range.start..range.end];
|
||||||
|
let color = highlight_color(*kind);
|
||||||
|
let mut s = span(slice).color(color);
|
||||||
|
if let Some(font) = highlight_font(*kind) {
|
||||||
|
s = s.font(font);
|
||||||
|
}
|
||||||
|
spans.push(s);
|
||||||
|
cursor = range.end;
|
||||||
|
}
|
||||||
|
if cursor < line.text.len() {
|
||||||
|
spans.push(plain_span(&line.text[cursor..], fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
Rich::with_spans(spans).size(Pixels(size)).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plain_span<'a>(text: &'a str, color: Color) -> TextSpan<'a, ()> {
|
||||||
|
span(text).color(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// stacks a click-out catcher behind a positioned menu pinned at the right-click anchor.
|
||||||
|
fn context_menu_overlay<'a>(
|
||||||
|
state: &'a BrowserState,
|
||||||
|
menu: &'a ContextMenu,
|
||||||
|
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
|
let dismiss = mouse_area(Space::new().width(Length::Fill).height(Length::Fill))
|
||||||
|
.on_press(BrowserMessage::HideContextMenu)
|
||||||
|
.on_right_press(BrowserMessage::HideContextMenu);
|
||||||
|
|
||||||
|
let full = state.context_acts_on_selection();
|
||||||
|
let positioned = positioned_menu(menu.anchor, menu_column(state, full));
|
||||||
|
|
||||||
|
stack![dismiss, positioned].into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// places the menu column at an absolute anchor by padding from the top-left.
|
||||||
|
fn positioned_menu<'a>(
|
||||||
|
anchor: Point,
|
||||||
|
inner: Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer>,
|
||||||
|
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
|
let menu = opaque(inner);
|
||||||
|
column![
|
||||||
|
Space::new().width(Length::Shrink).height(Length::Fixed(anchor.y.max(0.0))),
|
||||||
|
row![
|
||||||
|
Space::new().width(Length::Fixed(anchor.x.max(0.0))).height(Length::Shrink),
|
||||||
|
menu,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// renders the unified menu used by both the context menu and the menu bar.
|
||||||
|
/// `full` decides whether to show selection-dependent items beyond New Folder.
|
||||||
|
fn menu_column<'a>(
|
||||||
|
state: &'a BrowserState,
|
||||||
|
full: bool,
|
||||||
|
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
|
let p = palette::current();
|
||||||
|
let mut items: Vec<Element<_, _, _>> = Vec::new();
|
||||||
|
|
||||||
|
if full {
|
||||||
|
let single = state.single_selected();
|
||||||
|
if let Some(path) = &single {
|
||||||
|
let label = if path.is_dir() { "Open Folder" } else { "Open" };
|
||||||
|
items.push(menu_item(label, BrowserMessage::ContextOpen));
|
||||||
|
items.push(menu_item("Rename", BrowserMessage::ContextRename));
|
||||||
|
}
|
||||||
|
items.push(menu_item("Duplicate", BrowserMessage::ContextDuplicate));
|
||||||
|
items.push(menu_separator());
|
||||||
|
items.push(menu_item("Delete", BrowserMessage::ContextTrash));
|
||||||
|
items.push(menu_separator());
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(menu_item("New Folder", BrowserMessage::NewFolder));
|
||||||
|
if full {
|
||||||
|
items.push(menu_item("New Folder with Selection", BrowserMessage::NewFolderWithSelection));
|
||||||
|
}
|
||||||
|
|
||||||
|
container(column(items).spacing(0.0))
|
||||||
|
.width(Length::Fixed(220.0))
|
||||||
|
.padding(Padding { top: 4.0, right: 0.0, bottom: 4.0, left: 0.0 })
|
||||||
|
.style(move |_t: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(p.surface1)),
|
||||||
|
border: Border {
|
||||||
|
color: p.surface2,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
text_color: Some(p.text),
|
||||||
|
shadow: Default::default(),
|
||||||
|
snap: false,
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// one clickable row inside a menu.
|
||||||
|
fn menu_item(
|
||||||
|
label: &'static str,
|
||||||
|
msg: BrowserMessage,
|
||||||
|
) -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
|
let p = palette::current();
|
||||||
|
button(text(label).size(12.0).color(p.text))
|
||||||
|
.padding(Padding { top: 6.0, right: 12.0, bottom: 6.0, left: 12.0 })
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(move |_t: &Theme, status| {
|
||||||
|
let bg = match status {
|
||||||
|
button::Status::Hovered => Some(Background::Color(p.surface2)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: bg,
|
||||||
|
text_color: p.text,
|
||||||
|
border: Border::default(),
|
||||||
|
shadow: Default::default(),
|
||||||
|
snap: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_press(msg)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// a thin separator line between menu sections.
|
||||||
|
fn menu_separator() -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> {
|
||||||
|
let p = palette::current();
|
||||||
|
container(Space::new().width(Length::Fill).height(Length::Fixed(1.0)))
|
||||||
|
.padding(Padding { top: 4.0, right: 6.0, bottom: 4.0, left: 6.0 })
|
||||||
|
.style(move |_t: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(p.surface2)),
|
||||||
|
border: Border::default(),
|
||||||
|
text_color: None,
|
||||||
|
shadow: Default::default(),
|
||||||
|
snap: false,
|
||||||
|
})
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ pub mod oklab;
|
||||||
pub mod palette;
|
pub mod palette;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
mod syntax;
|
pub mod syntax;
|
||||||
pub mod table_block;
|
pub mod table_block;
|
||||||
pub mod text_block;
|
pub mod text_block;
|
||||||
pub mod text_widget;
|
pub mod text_widget;
|
||||||
|
|
@ -505,6 +505,20 @@ pub extern "C" fn browser_refresh(handle: *mut BrowserHandle) {
|
||||||
browser::handle::refresh(h);
|
browser::handle::refresh(h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// dispatches a numeric zoom command into the browser's scale state.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn browser_send_command(handle: *mut BrowserHandle, command: u32) {
|
||||||
|
let h = match unsafe { handle.as_mut() } { Some(h) => h, None => return };
|
||||||
|
let msg = match command {
|
||||||
|
7 => browser::BrowserMessage::ScaleUp,
|
||||||
|
8 => browser::BrowserMessage::ScaleDown,
|
||||||
|
9 => browser::BrowserMessage::ScaleReset,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
h.state.update(msg);
|
||||||
|
h.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub extern "C" fn viewport_render_mode(handle: *mut ViewportHandle) -> u32 {
|
pub extern "C" fn viewport_render_mode(handle: *mut ViewportHandle) -> u32 {
|
||||||
let h = match unsafe { handle.as_mut() } {
|
let h = match unsafe { handle.as_mut() } {
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,23 @@ impl App {
|
||||||
use iced_wgpu::core::keyboard;
|
use iced_wgpu::core::keyboard;
|
||||||
use iced_wgpu::core::Event as IcedEvent;
|
use iced_wgpu::core::Event as IcedEvent;
|
||||||
let pressed = event.state == ElementState::Pressed;
|
let pressed = event.state == ElementState::Pressed;
|
||||||
|
|
||||||
|
if pressed {
|
||||||
|
if let Some(action) = match_shortcut(self.modifiers, &event.logical_key) {
|
||||||
|
let msg = match action {
|
||||||
|
MenuAction::ZoomIn => Some(browser::BrowserMessage::ScaleUp),
|
||||||
|
MenuAction::ZoomOut => Some(browser::BrowserMessage::ScaleDown),
|
||||||
|
MenuAction::ZoomReset => Some(browser::BrowserMessage::ScaleReset),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(msg) = msg {
|
||||||
|
handle.state.update(msg);
|
||||||
|
handle.needs_redraw = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let modifiers = decode_winit_modifiers(self.modifiers);
|
let modifiers = decode_winit_modifiers(self.modifiers);
|
||||||
let key = winit_key_to_iced(&event.logical_key);
|
let key = winit_key_to_iced(&event.logical_key);
|
||||||
let text = event.text.as_ref().map(|s| iced_wgpu::core::SmolStr::new(s.as_str()));
|
let text = event.text.as_ref().map(|s| iced_wgpu::core::SmolStr::new(s.as_str()));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue