forked from jess/Acord
1
0
Fork 0

Document Browser Re-design replaced MacOS' Swift version and unifies all 3 platforms under one implementation.

This commit is contained in:
jess 2026-05-01 13:57:59 -07:00
parent 07550b5c31
commit 21c2aa8e95
14 changed files with 745 additions and 92 deletions

View File

@ -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()));

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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))

View File

@ -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 */

View File

@ -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),

View File

@ -1,4 +1,5 @@
pub mod model;
pub mod preview;
pub mod state;
pub mod ui;
pub mod handle;

View File

@ -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<PreviewLine>,
}
/// 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,
modified,
preview: folder_summary(&path),
preview_lines: Vec::new(),
});
} else {
let ext = path
@ -56,12 +60,16 @@ pub fn scan_directory(dir: &Path) -> Vec<BrowserItem> {
.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<PathBuf> {
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.
pub fn trash(item_path: &Path) -> std::io::Result<()> {
match trash_crate_remove(item_path) {

View File

@ -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
}
}

View File

@ -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<BrowserItem>,
pub selected: Option<PathBuf>,
pub selected: HashSet<PathBuf>,
pub selection_anchor: Option<PathBuf>,
pub scale: f32,
pub renaming: Option<PathBuf>,
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 context_menu: Option<ContextMenu>,
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<ContextTarget>,
}
#[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<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 => {
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<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 {
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<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;
}
}
}
}

View File

@ -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<Element<_, _, _>> = 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<Element<_, _, _>> = 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<Element<_, _, _>> = Vec::new();
for chunk in state.items.chunks(cols) {
let mut row_items: Vec<Element<_, _, _>> = 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<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()
}

View File

@ -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() } {

View File

@ -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()));