From 21c2aa8e95d4a33b6e61ab026b2bf3bda28f84a1 Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 1 May 2026 13:57:59 -0700 Subject: [PATCH] Document Browser Re-design replaced MacOS' Swift version and unifies all 3 platforms under one implementation. --- linux/src/app.rs | 17 ++ macos/src/AppDelegate.swift | 24 ++ macos/src/AppState.swift | 23 +- macos/src/DocumentBrowserWindow.swift | 8 + macos/src/IcedBrowserView.swift | 19 ++ viewport/include/acord.h | 5 + viewport/src/browser/handle.rs | 14 +- viewport/src/browser/mod.rs | 1 + viewport/src/browser/model.rs | 23 +- viewport/src/browser/preview.rs | 58 ++++ viewport/src/browser/state.rs | 207 ++++++++++++- viewport/src/browser/ui.rs | 405 ++++++++++++++++++++++---- viewport/src/lib.rs | 16 +- windows/src/app.rs | 17 ++ 14 files changed, 745 insertions(+), 92 deletions(-) create mode 100644 viewport/src/browser/preview.rs diff --git a/linux/src/app.rs b/linux/src/app.rs index 3c05483..aaf88c1 100644 --- a/linux/src/app.rs +++ b/linux/src/app.rs @@ -269,6 +269,23 @@ impl App { use iced_wgpu::core::keyboard; use iced_wgpu::core::Event as IcedEvent; 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 key = winit_key_to_iced(&event.logical_key); let text = event.text.as_ref().map(|s| iced_wgpu::core::SmolStr::new(s.as_str())); diff --git a/macos/src/AppDelegate.swift b/macos/src/AppDelegate.swift index 3b2b103..0885744 100644 --- a/macos/src/AppDelegate.swift +++ b/macos/src/AppDelegate.swift @@ -86,6 +86,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { observeDocumentTitle() observeDocumentText() + wireLoadedTextSync() syncThemeToViewport() syncGutterPrefsToViewport() syncSettingsToViewport() @@ -731,11 +732,19 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } @objc private func zoomIn() { + if let browser = DocumentBrowserController.shared, browser.isKeyWindow { + browser.sendCommand(7) + return + } ConfigManager.shared.zoomLevel += 1 NotificationCenter.default.post(name: .settingsChanged, object: nil) } @objc private func zoomOut() { + if let browser = DocumentBrowserController.shared, browser.isKeyWindow { + browser.sendCommand(8) + return + } let current = ConfigManager.shared.zoomLevel if 11 + current > 8 { ConfigManager.shared.zoomLevel -= 1 @@ -744,6 +753,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } @objc private func zoomReset() { + if let browser = DocumentBrowserController.shared, browser.isKeyWindow { + browser.sendCommand(9) + return + } ConfigManager.shared.zoomLevel = 0 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() { guard let w = window, let vp = w.contentView as? IcedViewportView else { return } let text = vp.getText() diff --git a/macos/src/AppState.swift b/macos/src/AppState.swift index 9c576a9..677a274 100644 --- a/macos/src/AppState.swift +++ b/macos/src/AppState.swift @@ -132,6 +132,8 @@ class AppState: ObservableObject { private var autoSaveDirty = false private var autoSaveCoolingDown = false 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 /// changed for the rest of the session. Stops the title-derived filename /// from re-deriving on every keystroke and littering the notes directory @@ -314,6 +316,7 @@ class AppState: ObservableObject { currentFileURL = nil currentFileFormat = .markdown refreshNoteList() + onLoadedTextChanged?(documentText) } func selectNote(_ id: UUID, extend: Bool = false, range: Bool = false) { @@ -346,6 +349,7 @@ class AppState: ObservableObject { documentText = bridge.getText(id) modified = false evaluate() + onLoadedTextChanged?(documentText) } } @@ -392,6 +396,15 @@ class AppState: ObservableObject { func loadNoteFromFile(_ url: URL) { let format = FileFormat.from(filename: url.lastPathComponent) 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 currentFileURL = url currentFileFormat = format @@ -400,19 +413,11 @@ class AppState: ObservableObject { } else { 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 let _ = bridge.cacheSave(id) evaluate() refreshNoteList() + onLoadedTextChanged?(documentText) } } diff --git a/macos/src/DocumentBrowserWindow.swift b/macos/src/DocumentBrowserWindow.swift index 198015b..498fb98 100644 --- a/macos/src/DocumentBrowserWindow.swift +++ b/macos/src/DocumentBrowserWindow.swift @@ -45,4 +45,12 @@ class DocumentBrowserController { 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) + } } diff --git a/macos/src/IcedBrowserView.swift b/macos/src/IcedBrowserView.swift index 30db90d..40ee8c2 100644 --- a/macos/src/IcedBrowserView.swift +++ b/macos/src/IcedBrowserView.swift @@ -90,6 +90,12 @@ class IcedBrowserView: NSView { 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) { super.setFrameSize(newSize) 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) } + 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) { guard let h = browserHandle else { return } browser_scroll_event(h, Float(event.scrollingDeltaX), Float(event.scrollingDeltaY)) diff --git a/viewport/include/acord.h b/viewport/include/acord.h index 6b25135..ececc08 100644 --- a/viewport/include/acord.h +++ b/viewport/include/acord.h @@ -142,6 +142,11 @@ char *browser_take_pending_open(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); #endif /* ACORD_VIEWPORT_H */ diff --git a/viewport/src/browser/handle.rs b/viewport/src/browser/handle.rs index 264218e..409fd69 100644 --- a/viewport/src/browser/handle.rs +++ b/viewport/src/browser/handle.rs @@ -150,7 +150,19 @@ pub fn render(handle: &mut BrowserHandle) { .events .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 mut ui = UserInterface::build( ui::view(&handle.state), diff --git a/viewport/src/browser/mod.rs b/viewport/src/browser/mod.rs index b67bb1b..ff2d145 100644 --- a/viewport/src/browser/mod.rs +++ b/viewport/src/browser/mod.rs @@ -1,4 +1,5 @@ pub mod model; +pub mod preview; pub mod state; pub mod ui; pub mod handle; diff --git a/viewport/src/browser/model.rs b/viewport/src/browser/model.rs index a442721..de8e4ae 100644 --- a/viewport/src/browser/model.rs +++ b/viewport/src/browser/model.rs @@ -1,8 +1,10 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; +use super::preview::{highlight_preview, PreviewLine}; + const SUPPORTED_EXTS: &[&str] = &["md", "txt", "markdown", "mdown"]; -const PREVIEW_LINES: usize = 20; +const PREVIEW_LINES: usize = 32; #[derive(Debug, Clone, PartialEq, Eq)] pub enum BrowserItemKind { @@ -17,6 +19,7 @@ pub struct BrowserItem { pub kind: BrowserItemKind, pub modified: SystemTime, pub preview: String, + pub preview_lines: Vec, } /// Folders first, then files; both in date-modified descending order. @@ -41,6 +44,7 @@ pub fn scan_directory(dir: &Path) -> Vec { kind: BrowserItemKind::Folder, modified, preview: folder_summary(&path), + preview_lines: Vec::new(), }); } else { let ext = path @@ -56,12 +60,16 @@ pub fn scan_directory(dir: &Path) -> Vec { .map(str::to_string) .unwrap_or(name); + let preview = file_preview(&path); + let preview_lines = highlight_preview(&preview); + files.push(BrowserItem { path: path.clone(), name: display, kind: BrowserItemKind::File, modified, - preview: file_preview(&path), + preview, + preview_lines, }); } } @@ -208,6 +216,17 @@ pub fn create_folder(parent: &Path) -> std::io::Result { 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 { + 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. pub fn trash(item_path: &Path) -> std::io::Result<()> { match trash_crate_remove(item_path) { diff --git a/viewport/src/browser/preview.rs b/viewport/src/browser/preview.rs new file mode 100644 index 0000000..7b2c9af --- /dev/null +++ b/viewport/src/browser/preview.rs @@ -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, u8)>, + pub heading: Option, +} + +/// highlights source line-by-line with a fresh per-preview user-ident rainbow. +pub fn highlight_preview(source: &str) -> Vec { + 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, 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 { + 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 + } +} diff --git a/viewport/src/browser/state.rs b/viewport/src/browser/state.rs index 7b92c3c..8112743 100644 --- a/viewport/src/browser/state.rs +++ b/viewport/src/browser/state.rs @@ -1,23 +1,36 @@ +use std::collections::HashSet; use std::path::PathBuf; +use iced_wgpu::core::keyboard::Modifiers; +use iced_wgpu::core::Point; + use super::model::{self, BrowserItem, BrowserItemKind}; pub struct BrowserState { pub root: PathBuf, pub current: PathBuf, pub items: Vec, - pub selected: Option, + pub selected: HashSet, + pub selection_anchor: Option, pub scale: f32, pub renaming: Option, 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, pub context_menu: Option, + pub current_modifiers: Modifiers, + pub cursor_pos: Point, } #[derive(Debug, Clone)] pub struct ContextMenu { - pub anchor: iced_wgpu::core::Point, + pub anchor: Point, + /// None when the right-click landed between cards. + pub target: Option, +} + +#[derive(Debug, Clone)] +pub struct ContextTarget { pub item_path: PathBuf, pub is_file: bool, } @@ -34,11 +47,18 @@ pub enum BrowserMessage { Duplicate(PathBuf), Trash(PathBuf), NewFolder, + NewFolderWithSelection, ScaleUp, ScaleDown, + ScaleReset, Refresh, - ShowContextMenu { anchor: iced_wgpu::core::Point, path: PathBuf, is_file: bool }, + ShowContextMenu { path: PathBuf, is_file: bool }, + ShowEmptyContextMenu, HideContextMenu, + ContextOpen, + ContextRename, + ContextDuplicate, + ContextTrash, } impl BrowserState { @@ -49,24 +69,29 @@ impl BrowserState { root, current, items, - selected: None, + selected: HashSet::new(), + selection_anchor: None, scale: 1.0, renaming: None, rename_text: String::new(), pending_open: None, context_menu: None, + current_modifiers: Modifiers::empty(), + cursor_pos: Point::ORIGIN, } } pub fn refresh(&mut self) { self.items = model::scan_directory(&self.current); + self.prune_selection(); } pub fn update(&mut self, msg: BrowserMessage) { match msg { BrowserMessage::NavigateTo(path) => { self.current = path; - self.selected = None; + self.selected.clear(); + self.selection_anchor = None; self.renaming = None; self.context_menu = None; self.refresh(); @@ -76,7 +101,7 @@ impl BrowserState { self.context_menu = None; } BrowserMessage::Select(path) => { - self.selected = Some(path); + self.apply_selection(path); self.context_menu = None; } BrowserMessage::StartRename(path) => { @@ -111,31 +136,103 @@ impl BrowserState { } BrowserMessage::Trash(path) => { let _ = model::trash(&path); - if self.selected.as_deref() == Some(&path) { - self.selected = None; + self.selected.remove(&path); + if self.selection_anchor.as_deref() == Some(&path) { + self.selection_anchor = None; } self.context_menu = None; self.refresh(); } BrowserMessage::NewFolder => { - let _ = model::create_folder(&self.current); - self.refresh(); + self.context_menu = None; + if let Ok(folder) = model::create_folder(&self.current) { + self.refresh(); + self.start_renaming(folder); + } + } + BrowserMessage::NewFolderWithSelection => { + self.context_menu = None; + let items: Vec = 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 => { - self.scale = (self.scale + 0.1).min(3.0); + self.scale = (self.scale * 14.0 / 13.0).min(3.0); } 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 => { self.refresh(); } - BrowserMessage::ShowContextMenu { anchor, path, is_file } => { - self.context_menu = Some(ContextMenu { anchor, item_path: path, is_file }); + BrowserMessage::ShowContextMenu { 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 => { 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 = self.selected.iter().cloned().collect(); + for path in targets { + let _ = model::duplicate(&path); + } + self.refresh(); + } + BrowserMessage::ContextTrash => { + self.context_menu = None; + let targets: Vec = 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 { - self.selected.as_deref() == Some(&item.path) + self.selected.contains(&item.path) } pub fn item_kind_is_file(item: &BrowserItem) -> bool { 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 { + 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 = 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; + } + } + } } diff --git a/viewport/src/browser/ui.rs b/viewport/src/browser/ui.rs index b3f3fdc..8f870c4 100644 --- a/viewport/src/browser/ui.rs +++ b/viewport/src/browser/ui.rs @@ -1,30 +1,53 @@ -use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Theme}; -use iced_widget::{button, column, container, mouse_area, row, scrollable, text, text_input, Space}; +use iced_wgpu::core::text::{Span as TextSpan, Wrapping}; +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::syntax::{highlight_color, highlight_font}; 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 CARD_BASE_W: f32 = 240.0; +const TARGET_CARD_W: f32 = 280.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> { let p = palette::current(); - let body: Element<_, _, _> = if state.items.is_empty() { + let body_inner: Element<_, _, _> = if state.items.is_empty() { empty_state() } 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), rule(p.surface1), 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 { background: Some(Background::Color(p.base)), 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 mut row_items: Vec> = Vec::new(); + for (i, (name, path)) in segments.into_iter().enumerate() { if i > 0 { row_items.push( @@ -107,98 +131,110 @@ fn empty_state() -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> .into() } -fn grid(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> { - let scale = state.scale; - let mut rows: Vec> = Vec::new(); - let chunk_size = CARDS_PER_ROW; +/// picks the column count whose card-width sits closest to the scale-adjusted target. +fn columns_for_width(avail_w: f32, scale: f32) -> usize { + let target = TARGET_CARD_W * scale; + 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> = Vec::new(); + for chunk in state.items.chunks(cols) { let mut row_items: Vec> = Vec::new(); 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() < chunk_size { + while row_items.len() < cols { row_items.push( Space::new() - .width(Length::Fill) - .height(Length::Shrink) + .width(Length::Fixed(card_w)) + .height(Length::Fixed(card_h)) .into() ); } rows.push( row(row_items) - .spacing(16.0 * scale) + .spacing(GAP * scale) .into() ); } container( column(rows) - .spacing(16.0 * scale) + .spacing(GAP * scale) .width(Length::Fill) ) - .padding(16.0 * scale) + .padding(OUTER_PAD) .width(Length::Fill) .into() } +/// stacks a kind-specific preview above a title strip inside one click target. fn card<'a>( item: &'a BrowserItem, state: &'a BrowserState, scale: f32, + card_w: f32, + card_h: f32, ) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> { let p = palette::current(); let selected = state.is_selected(item); let renaming = state.is_renaming(item); - let preview_h = (CARD_BASE_W * scale) * 0.55; - let card_w = CARD_BASE_W * scale; + let title_size = 12.0 * 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 { - BrowserItemKind::Folder => container( - row![ - 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(), + BrowserItemKind::Folder => folder_preview(&item.preview, scale, preview_h), + BrowserItemKind::File => file_preview(&item.preview_lines, scale, preview_h), }; let title: Element<_, _, _> = if renaming { text_input("Name", &state.rename_text) .on_input(BrowserMessage::UpdateRename) .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 }) .into() } 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); @@ -208,7 +244,9 @@ fn card<'a>( let body = container(content) .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 { background: Some(Background::Color(if selected { p.surface1 } else { p.surface0 })), border: Border { @@ -221,17 +259,258 @@ fn card<'a>( snap: false, }); - let click_msg = match item.kind { + let open_msg = match item.kind { BrowserItemKind::Folder => BrowserMessage::NavigateTo(item_path.clone()), BrowserItemKind::File => BrowserMessage::Open(item_path.clone()), }; mouse_area(body) - .on_press(click_msg) + .on_press(BrowserMessage::Select(item_path.clone())) + .on_double_click(open_msg) .on_right_press(BrowserMessage::ShowContextMenu { - anchor: iced_wgpu::core::Point::new(0.0, 0.0), path: item_path, is_file, }) .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> = 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> = 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> = 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() +} diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index d380a3c..622919a 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -14,7 +14,7 @@ pub mod oklab; pub mod palette; pub mod selection; pub mod sidecar; -mod syntax; +pub mod syntax; pub mod table_block; pub mod text_block; pub mod text_widget; @@ -505,6 +505,20 @@ pub extern "C" fn browser_refresh(handle: *mut BrowserHandle) { 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)] pub extern "C" fn viewport_render_mode(handle: *mut ViewportHandle) -> u32 { let h = match unsafe { handle.as_mut() } { diff --git a/windows/src/app.rs b/windows/src/app.rs index 7a5ac03..943d318 100644 --- a/windows/src/app.rs +++ b/windows/src/app.rs @@ -400,6 +400,23 @@ impl App { use iced_wgpu::core::keyboard; use iced_wgpu::core::Event as IcedEvent; 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 key = winit_key_to_iced(&event.logical_key); let text = event.text.as_ref().map(|s| iced_wgpu::core::SmolStr::new(s.as_str()));