forked from jess/Acord
1
0
Fork 0

Implemented Docs Browser for Windows and Linux

This commit is contained in:
jess 2026-04-29 02:21:10 -07:00
parent 2fb2843caa
commit 8111a8164a
15 changed files with 1588 additions and 1116 deletions

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@ use acord_viewport::{
viewport_send_command, viewport_free_string, viewport_send_command, viewport_free_string,
ViewportHandle, ViewportHandle,
}; };
use acord_viewport::browser::{self, BrowserHandle};
use crate::config::Config; use crate::config::Config;
use crate::shortcuts::{match_shortcut, MenuAction}; use crate::shortcuts::{match_shortcut, MenuAction};
@ -31,6 +32,11 @@ pub struct App {
current_file: Option<PathBuf>, current_file: Option<PathBuf>,
last_autosave_attempt: Instant, last_autosave_attempt: Instant,
last_autosaved_hash: Option<u64>, last_autosaved_hash: Option<u64>,
browser_window: Option<Window>,
browser_handle: Option<BrowserHandle>,
browser_cursor: PhysicalPosition<f64>,
browser_scale: f32,
} }
impl App { impl App {
@ -45,6 +51,10 @@ impl App {
current_file: None, current_file: None,
last_autosave_attempt: Instant::now(), last_autosave_attempt: Instant::now(),
last_autosaved_hash: None, last_autosaved_hash: None,
browser_window: None,
browser_handle: None,
browser_cursor: PhysicalPosition::new(0.0, 0.0),
browser_scale: 1.0,
} }
} }
@ -93,6 +103,154 @@ impl App {
} }
} }
MenuAction::ExportCrate => { /* TODO: wire crate export */ } MenuAction::ExportCrate => { /* TODO: wire crate export */ }
MenuAction::ToggleBrowser => self.toggle_browser(event_loop),
}
}
fn toggle_browser(&mut self, event_loop: &ActiveEventLoop) {
if self.browser_window.is_some() {
self.close_browser();
} else {
self.open_browser(event_loop);
}
}
fn open_browser(&mut self, event_loop: &ActiveEventLoop) {
let mut attrs = WindowAttributes::default()
.with_title("Documents - Acord")
.with_inner_size(LogicalSize::new(900.0, 650.0));
if let Some(icon) = load_window_icon() {
attrs = attrs.with_window_icon(Some(icon));
}
let window = match event_loop.create_window(attrs) {
Ok(w) => w,
Err(_) => return,
};
self.browser_scale = window.scale_factor() as f32;
let size = window.inner_size();
let w = size.width as f32 / self.browser_scale;
let h = size.height as f32 / self.browser_scale;
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
let display = match window.display_handle() {
Ok(d) => d.as_raw(),
Err(_) => return,
};
let win_handle = match window.window_handle() {
Ok(w) => w.as_raw(),
Err(_) => return,
};
let notes_dir = self.config.notes_dir();
let _ = std::fs::create_dir_all(&notes_dir);
match browser::handle::create(display, win_handle, w, h, self.browser_scale, notes_dir) {
Some(handle) => {
self.browser_handle = Some(handle);
self.browser_window = Some(window);
}
None => drop(window),
}
}
fn close_browser(&mut self) {
self.browser_handle = None;
self.browser_window = None;
}
fn drain_browser_open(&mut self) {
let Some(handle) = self.browser_handle.as_mut() else { return };
let Some(path) = browser::handle::take_pending_open(handle) else { return };
if let Ok(text) = std::fs::read_to_string(&path) {
let c = CString::new(text).unwrap_or_default();
viewport_set_text(self.handle, c.as_ptr());
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md");
let c_ext = CString::new(ext).unwrap();
viewport_set_lang(self.handle, c_ext.as_ptr());
if let Some(w) = &self.window {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord");
w.set_title(&format!("{name} - Acord"));
w.focus_window();
}
self.current_file = Some(path);
self.last_autosaved_hash = None;
}
self.close_browser();
}
fn handle_browser_event(&mut self, event: WindowEvent) {
let Some(handle) = self.browser_handle.as_mut() else { return };
match event {
WindowEvent::CloseRequested => {
self.close_browser();
}
WindowEvent::Resized(size) => {
let w = size.width as f32 / self.browser_scale;
let h = size.height as f32 / self.browser_scale;
browser::handle::resize(handle, w, h, self.browser_scale);
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.browser_scale = scale_factor as f32;
if let Some(win) = &self.browser_window {
let size = win.inner_size();
let w = size.width as f32 / self.browser_scale;
let h = size.height as f32 / self.browser_scale;
browser::handle::resize(handle, w, h, self.browser_scale);
}
}
WindowEvent::RedrawRequested => {
browser::handle::render(handle);
}
WindowEvent::CursorMoved { position, .. } => {
self.browser_cursor = position;
let x = position.x as f32 / self.browser_scale;
let y = position.y as f32 / self.browser_scale;
browser::handle::push_mouse_move(handle, x, y);
}
WindowEvent::MouseInput { state, button, .. } => {
let pressed = state == ElementState::Pressed;
browser::handle::push_mouse_button(handle, Self::winit_button(button), pressed);
}
WindowEvent::MouseWheel { delta, .. } => {
let (dx, dy) = match delta {
MouseScrollDelta::LineDelta(dx, dy) => (dx * 20.0, dy * 20.0),
MouseScrollDelta::PixelDelta(d) => (d.x as f32, d.y as f32),
};
browser::handle::push_scroll(handle, dx, -dy);
}
WindowEvent::KeyboardInput { event, .. } => {
use iced_wgpu::core::keyboard;
use iced_wgpu::core::Event as IcedEvent;
let pressed = event.state == ElementState::Pressed;
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()));
let physical_key = keyboard::key::Physical::Unidentified(keyboard::key::NativeCode::Unidentified);
let location = keyboard::Location::Standard;
let modified_key = key.clone();
let ev = if pressed {
keyboard::Event::KeyPressed {
key, modified_key, physical_key, location, modifiers, text,
repeat: event.repeat,
}
} else {
keyboard::Event::KeyReleased {
key, modified_key, physical_key, location, modifiers,
}
};
browser::handle::push_event(handle, IcedEvent::Keyboard(ev));
}
WindowEvent::ModifiersChanged(mods) => {
self.modifiers = mods.state();
use iced_wgpu::core::keyboard;
use iced_wgpu::core::Event as IcedEvent;
browser::handle::push_event(
handle,
IcedEvent::Keyboard(keyboard::Event::ModifiersChanged(decode_winit_modifiers(mods.state()))),
);
}
_ => {}
} }
} }
@ -232,7 +390,13 @@ impl ApplicationHandler for App {
self.window = Some(window); self.window = Some(window);
} }
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
let is_browser = self.browser_window.as_ref().map(|w| w.id() == id).unwrap_or(false);
if is_browser {
self.handle_browser_event(event);
return;
}
if self.handle.is_null() { return; } if self.handle.is_null() { return; }
match event { match event {
@ -340,11 +504,15 @@ impl ApplicationHandler for App {
self.last_autosave_attempt = Instant::now(); self.last_autosave_attempt = Instant::now();
self.try_autosave(); self.try_autosave();
} }
self.drain_browser_open();
if let Some(w) = &self.window { if let Some(w) = &self.window {
if !self.handle.is_null() { if !self.handle.is_null() {
w.request_redraw(); w.request_redraw();
} }
} }
if let Some(w) = &self.browser_window {
w.request_redraw();
}
} }
} }
@ -355,6 +523,34 @@ fn text_hash(s: &str) -> u64 {
h.finish() h.finish()
} }
/// Translates winit logical keys into iced keyboard keys for direct iced
/// event push (used by the second browser window, which speaks iced
/// directly rather than through the C bridge).
fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key {
use iced_wgpu::core::keyboard::{key as ikey, Key as IKey};
match key {
Key::Named(n) => match n {
NamedKey::Enter => IKey::Named(ikey::Named::Enter),
NamedKey::Tab => IKey::Named(ikey::Named::Tab),
NamedKey::Backspace => IKey::Named(ikey::Named::Backspace),
NamedKey::Escape => IKey::Named(ikey::Named::Escape),
NamedKey::Delete => IKey::Named(ikey::Named::Delete),
NamedKey::ArrowLeft => IKey::Named(ikey::Named::ArrowLeft),
NamedKey::ArrowRight => IKey::Named(ikey::Named::ArrowRight),
NamedKey::ArrowUp => IKey::Named(ikey::Named::ArrowUp),
NamedKey::ArrowDown => IKey::Named(ikey::Named::ArrowDown),
NamedKey::Home => IKey::Named(ikey::Named::Home),
NamedKey::End => IKey::Named(ikey::Named::End),
NamedKey::PageUp => IKey::Named(ikey::Named::PageUp),
NamedKey::PageDown => IKey::Named(ikey::Named::PageDown),
NamedKey::Space => IKey::Named(ikey::Named::Space),
_ => IKey::Unidentified,
},
Key::Character(s) => IKey::Character(iced_wgpu::core::SmolStr::new(s.as_str())),
_ => IKey::Unidentified,
}
}
/// Maps winit logical keys to the macOS-style virtual keycodes the bridge /// Maps winit logical keys to the macOS-style virtual keycodes the bridge
/// expects. Character keys go through `text` instead, so 0 is fine for those. /// expects. Character keys go through `text` instead, so 0 is fine for those.
fn winit_key_to_code(key: &Key) -> u32 { fn winit_key_to_code(key: &Key) -> u32 {

View File

@ -24,6 +24,7 @@ pub enum MenuAction {
Find, Find,
Settings, Settings,
ExportCrate, ExportCrate,
ToggleBrowser,
} }
/// Matches an app-level shortcut. Returns Some(action) for combos that should /// Matches an app-level shortcut. Returns Some(action) for combos that should
@ -31,6 +32,16 @@ pub enum MenuAction {
/// viewport (cut/copy/paste/undo/redo/select-all are handled inside iced via /// viewport (cut/copy/paste/undo/redo/select-all are handled inside iced via
/// the Ctrl→LOGO modifier alias, plain typing, navigation, etc.). /// the Ctrl→LOGO modifier alias, plain typing, navigation, etc.).
pub fn match_shortcut(modifiers: ModifiersState, key: &Key) -> Option<MenuAction> { pub fn match_shortcut(modifiers: ModifiersState, key: &Key) -> Option<MenuAction> {
// Alt+B mirrors macOS Ctrl+B for the document browser. Mac-Cmd maps to
// Ctrl on Linux/Windows, so Mac-Ctrl gets bumped to Alt to avoid collision.
if modifiers.alt_key() && !modifiers.control_key() && !modifiers.super_key() {
if let Key::Character(s) = key {
if ascii_lower(s) == 'b' {
return Some(MenuAction::ToggleBrowser);
}
}
}
if !modifiers.control_key() { if !modifiers.control_key() {
return None; return None;
} }

View File

@ -26,6 +26,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
base64 = "0.22" base64 = "0.22"
arboard = "3" arboard = "3"
ureq = "3" ureq = "3"
trash = "5"
[build-dependencies] [build-dependencies]
cbindgen = "0.29" cbindgen = "0.29"

View File

@ -0,0 +1,262 @@
use std::path::PathBuf;
use iced_graphics::{Shell, Viewport};
use iced_runtime::user_interface::{self, UserInterface};
use iced_wgpu::core::renderer::Style;
use iced_wgpu::core::{
clipboard, mouse, window, Color, Event, Font, Pixels, Point, Size, Theme,
};
use iced_wgpu::Engine;
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
use crate::palette;
use super::state::{BrowserMessage, BrowserState};
use super::ui;
/// Owns the browser window's wgpu surface, iced renderer, and BrowserState.
pub struct BrowserHandle {
pub surface: wgpu::Surface<'static>,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub format: wgpu::TextureFormat,
pub width: u32,
pub height: u32,
pub scale: f32,
pub renderer: iced_wgpu::Renderer,
pub viewport: Viewport,
pub cache: user_interface::Cache,
pub state: BrowserState,
pub events: Vec<Event>,
pub cursor: mouse::Cursor,
pub needs_redraw: bool,
}
/// The browser doesn't read or write the system clipboard.
struct NoopClipboard;
impl clipboard::Clipboard for NoopClipboard {
fn read(&self, _kind: clipboard::Kind) -> Option<String> { None }
fn write(&mut self, _kind: clipboard::Kind, _contents: String) {}
}
/// Caller must keep the underlying winit Window alive for the surface's lifetime.
pub fn create(
raw_display: RawDisplayHandle,
raw_window: RawWindowHandle,
width: f32,
height: f32,
scale: f32,
notes_dir: PathBuf,
) -> Option<BrowserHandle> {
#[cfg(target_os = "macos")]
let backends = wgpu::Backends::METAL;
#[cfg(target_os = "windows")]
let backends = wgpu::Backends::DX12;
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let backends = wgpu::Backends::VULKAN;
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends,
..Default::default()
});
let target = wgpu::SurfaceTargetUnsafe::RawHandle {
raw_display_handle: raw_display,
raw_window_handle: raw_window,
};
let surface = unsafe { instance.create_surface_unsafe(target).ok()? };
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
}))
.ok()?;
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
let phys_w = (width * scale) as u32;
let phys_h = (height * scale) as u32;
let caps = surface.get_capabilities(&adapter);
let format = caps.formats.first().copied()?;
surface.configure(
&device,
&wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: phys_w.max(1),
height: phys_h.max(1),
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: caps
.alpha_modes
.first()
.copied()
.unwrap_or(wgpu::CompositeAlphaMode::Auto),
view_formats: vec![],
desired_maximum_frame_latency: 2,
},
);
let engine = Engine::new(&adapter, device.clone(), queue.clone(), format, None, Shell::headless());
let renderer = iced_wgpu::Renderer::new(engine, Font::DEFAULT, Pixels(13.0));
let viewport = Viewport::with_physical_size(Size::new(phys_w.max(1), phys_h.max(1)), scale);
Some(BrowserHandle {
surface,
device,
queue,
format,
width: phys_w,
height: phys_h,
scale,
renderer,
viewport,
cache: user_interface::Cache::new(),
state: BrowserState::new(notes_dir),
events: Vec::new(),
cursor: mouse::Cursor::Available(Point::new(0.0, 0.0)),
needs_redraw: true,
})
}
/// One frame: drains pending events into messages, applies them, then redraws.
pub fn render(handle: &mut BrowserHandle) {
let pending = !handle.events.is_empty();
if !handle.needs_redraw && !pending {
return;
}
let frame = match handle.surface.get_current_texture() {
Ok(f) => f,
Err(_) => return,
};
let view = frame.texture.create_view(&Default::default());
let logical_size = handle.viewport.logical_size();
handle
.events
.push(Event::Window(window::Event::RedrawRequested(iced_wgpu::core::time::Instant::now())));
// First UI build receives input events and emits messages.
let cache = std::mem::take(&mut handle.cache);
let mut ui = UserInterface::build(
ui::view(&handle.state),
Size::new(logical_size.width, logical_size.height),
cache,
&mut handle.renderer,
);
let mut clipboard = NoopClipboard;
let mut messages: Vec<BrowserMessage> = Vec::new();
let _ = ui.update(
&handle.events,
handle.cursor,
&mut handle.renderer,
&mut clipboard,
&mut messages,
);
handle.events.clear();
let cache = ui.into_cache();
for msg in messages.drain(..) {
handle.state.update(msg);
}
// Second UI build draws against post-message state.
let mut ui = UserInterface::build(
ui::view(&handle.state),
Size::new(logical_size.width, logical_size.height),
cache,
&mut handle.renderer,
);
let theme = Theme::Dark;
let style = Style { text_color: Color::WHITE };
ui.draw(&mut handle.renderer, &theme, &style, handle.cursor);
handle.cache = ui.into_cache();
handle
.renderer
.present(Some(palette::current().base), handle.format, &view, &handle.viewport);
frame.present();
handle.needs_redraw = false;
}
pub fn resize(handle: &mut BrowserHandle, width: f32, height: f32, scale: f32) {
let phys_w = (width * scale) as u32;
let phys_h = (height * scale) as u32;
if phys_w == 0 || phys_h == 0 { return; }
handle.width = phys_w;
handle.height = phys_h;
handle.scale = scale;
handle.viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale);
handle.surface.configure(
&handle.device,
&wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: handle.format,
width: phys_w,
height: phys_h,
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: wgpu::CompositeAlphaMode::Auto,
view_formats: vec![],
desired_maximum_frame_latency: 2,
},
);
handle.needs_redraw = true;
}
pub fn push_mouse_move(handle: &mut BrowserHandle, x: f32, y: f32) {
let position = Point::new(x, y);
handle.cursor = mouse::Cursor::Available(position);
handle.events.push(Event::Mouse(mouse::Event::CursorMoved { position }));
handle.needs_redraw = true;
}
pub fn push_mouse_button(handle: &mut BrowserHandle, button: u8, pressed: bool) {
let btn = match button {
0 => mouse::Button::Left,
1 => mouse::Button::Right,
2 => mouse::Button::Middle,
n => mouse::Button::Other(n as u16),
};
let ev = if pressed {
mouse::Event::ButtonPressed(btn)
} else {
mouse::Event::ButtonReleased(btn)
};
handle.events.push(Event::Mouse(ev));
handle.needs_redraw = true;
}
pub fn push_scroll(handle: &mut BrowserHandle, delta_x: f32, delta_y: f32) {
handle.events.push(Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: delta_x, y: delta_y },
}));
handle.needs_redraw = true;
}
pub fn push_event(handle: &mut BrowserHandle, event: Event) {
handle.events.push(event);
handle.needs_redraw = true;
}
pub fn take_pending_open(handle: &mut BrowserHandle) -> Option<PathBuf> {
handle.state.take_pending_open()
}
pub fn refresh(handle: &mut BrowserHandle) {
handle.state.refresh();
handle.needs_redraw = true;
}

View File

@ -0,0 +1,8 @@
pub mod model;
pub mod state;
pub mod ui;
pub mod handle;
pub use model::{BrowserItem, BrowserItemKind};
pub use state::{BrowserState, BrowserMessage};
pub use handle::BrowserHandle;

View File

@ -0,0 +1,264 @@
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const SUPPORTED_EXTS: &[&str] = &["md", "txt", "markdown", "mdown"];
const PREVIEW_LINES: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BrowserItemKind {
File,
Folder,
}
#[derive(Debug, Clone)]
pub struct BrowserItem {
pub path: PathBuf,
pub name: String,
pub kind: BrowserItemKind,
pub modified: SystemTime,
pub preview: String,
}
/// Folders first, then files; both in date-modified descending order.
pub fn scan_directory(dir: &Path) -> Vec<BrowserItem> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
let mut folders: Vec<BrowserItem> = Vec::new();
let mut files: Vec<BrowserItem> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') { continue; }
let Ok(meta) = entry.metadata() else { continue };
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
if meta.is_dir() {
folders.push(BrowserItem {
path: path.clone(),
name,
kind: BrowserItemKind::Folder,
modified,
preview: folder_summary(&path),
});
} else {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
if !SUPPORTED_EXTS.iter().any(|e| *e == ext) { continue; }
let display = path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
.unwrap_or(name);
files.push(BrowserItem {
path: path.clone(),
name: display,
kind: BrowserItemKind::File,
modified,
preview: file_preview(&path),
});
}
}
folders.sort_by(|a, b| b.modified.cmp(&a.modified));
files.sort_by(|a, b| b.modified.cmp(&a.modified));
folders.extend(files);
folders
}
pub fn file_preview(path: &Path) -> String {
let Ok(text) = std::fs::read_to_string(path) else { return String::new() };
let body = strip_sidecar_archive(&text);
if body_looks_blank(body) {
return "(empty note)".to_string();
}
body.lines().take(PREVIEW_LINES).collect::<Vec<_>>().join("\n")
}
pub fn folder_summary(dir: &Path) -> String {
let Ok(entries) = std::fs::read_dir(dir) else { return "Empty".to_string() };
let mut files = 0usize;
let mut folders = 0usize;
for entry in entries.flatten() {
let name = entry.file_name();
let s = name.to_string_lossy();
if s.starts_with('.') { continue; }
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
folders += 1;
} else {
let ext = entry.path()
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
if SUPPORTED_EXTS.iter().any(|e| *e == ext) { files += 1; }
}
}
let mut parts: Vec<String> = Vec::new();
if files > 0 {
parts.push(format!("{} file{}", files, if files == 1 { "" } else { "s" }));
}
if folders > 0 {
parts.push(format!("{} folder{}", folders, if folders == 1 { "" } else { "s" }));
}
if parts.is_empty() { "Empty".to_string() } else { parts.join(", ") }
}
/// Cuts the file at the start of the embedded base64 archive comment.
fn strip_sidecar_archive(text: &str) -> &str {
match text.find("<!-- acord-archive") {
Some(idx) => &text[..idx],
None => text,
}
}
/// True when the body has nothing but whitespace, separator rows, or default-header tables.
fn body_looks_blank(body: &str) -> bool {
let trimmed = body.trim();
if trimmed.is_empty() { return true; }
for raw in trimmed.lines() {
let t = raw.trim();
if t.is_empty() { continue; }
if !t.starts_with('|') { return false; }
let cells: Vec<&str> = t
.trim_matches('|')
.split('|')
.map(str::trim)
.collect();
let separator = !cells.is_empty()
&& cells.iter().all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':'));
if separator { continue; }
let default_header = cells.iter().enumerate().all(|(i, c)| *c == format!("Header {}", i + 1));
if cells.iter().all(|c| c.is_empty()) || default_header { continue; }
return false;
}
true
}
pub fn rename(item_path: &Path, new_name: &str, is_file: bool) -> std::io::Result<PathBuf> {
let trimmed = new_name.trim();
if trimmed.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty name"));
}
let parent = item_path.parent().unwrap_or_else(|| Path::new(""));
let dest = if is_file {
let ext = item_path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext.is_empty() {
parent.join(trimmed)
} else {
parent.join(format!("{}.{}", trimmed, ext))
}
} else {
parent.join(trimmed)
};
if dest.exists() {
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "destination exists"));
}
std::fs::rename(item_path, &dest)?;
Ok(dest)
}
/// Copies the file to a sibling with a `name N.ext` suffix, picking the lowest free N.
pub fn duplicate(item_path: &Path) -> std::io::Result<PathBuf> {
let parent = item_path.parent().unwrap_or_else(|| Path::new(""));
let stem = item_path.file_stem().and_then(|s| s.to_str()).unwrap_or("copy");
let ext = item_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let mut n = 1usize;
let dest = loop {
let candidate = if ext.is_empty() {
parent.join(format!("{} {}", stem, n))
} else {
parent.join(format!("{} {}.{}", stem, n, ext))
};
if !candidate.exists() { break candidate; }
n += 1;
};
std::fs::copy(item_path, &dest)?;
Ok(dest)
}
pub fn move_into(item_path: &Path, folder: &Path) -> std::io::Result<PathBuf> {
let name = item_path.file_name().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "no file name")
})?;
let dest = folder.join(name);
if dest.exists() {
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "destination exists"));
}
std::fs::rename(item_path, &dest)?;
Ok(dest)
}
pub fn create_folder(parent: &Path) -> std::io::Result<PathBuf> {
let mut name = "New Folder".to_string();
let mut n = 1usize;
while parent.join(&name).exists() {
n += 1;
name = format!("New Folder {}", n);
}
let dest = parent.join(name);
std::fs::create_dir(&dest)?;
Ok(dest)
}
/// 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) {
Ok(()) => Ok(()),
Err(_) => {
if item_path.is_dir() {
std::fs::remove_dir_all(item_path)
} else {
std::fs::remove_file(item_path)
}
}
}
}
fn trash_crate_remove(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
trash::delete(path).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
pub fn path_segments(current: &Path, root: &Path) -> Vec<(String, PathBuf)> {
let mut segments: Vec<(String, PathBuf)> = Vec::new();
let mut p = current.to_path_buf();
while p != root && p.starts_with(root) {
let name = p.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
segments.insert(0, (name, p.clone()));
match p.parent() {
Some(parent) => p = parent.to_path_buf(),
None => break,
}
}
segments.insert(0, ("Documents".to_string(), root.to_path_buf()));
segments
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blank_body_detection() {
assert!(body_looks_blank(""));
assert!(body_looks_blank(" \n\n "));
assert!(body_looks_blank("| | |\n| - | - |\n| | |"));
assert!(body_looks_blank("| Header 1 | Header 2 |\n| -------- | -------- |"));
assert!(!body_looks_blank("hello"));
assert!(!body_looks_blank("| a | b |\n| - | - |\n| 1 | 2 |"));
}
#[test]
fn sidecar_strip() {
let text = "body line\n\n<!-- acord-archive\nABCDEF\n-->\n";
assert_eq!(strip_sidecar_archive(text), "body line\n\n");
assert_eq!(strip_sidecar_archive("plain"), "plain");
}
}

View File

@ -0,0 +1,161 @@
use std::path::PathBuf;
use super::model::{self, BrowserItem, BrowserItemKind};
pub struct BrowserState {
pub root: PathBuf,
pub current: PathBuf,
pub items: Vec<BrowserItem>,
pub selected: 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.
pub pending_open: Option<PathBuf>,
pub context_menu: Option<ContextMenu>,
}
#[derive(Debug, Clone)]
pub struct ContextMenu {
pub anchor: iced_wgpu::core::Point,
pub item_path: PathBuf,
pub is_file: bool,
}
#[derive(Debug, Clone)]
pub enum BrowserMessage {
NavigateTo(PathBuf),
Open(PathBuf),
Select(PathBuf),
StartRename(PathBuf),
UpdateRename(String),
CommitRename,
CancelRename,
Duplicate(PathBuf),
Trash(PathBuf),
NewFolder,
ScaleUp,
ScaleDown,
Refresh,
ShowContextMenu { anchor: iced_wgpu::core::Point, path: PathBuf, is_file: bool },
HideContextMenu,
}
impl BrowserState {
pub fn new(root: PathBuf) -> Self {
let current = root.clone();
let items = model::scan_directory(&current);
Self {
root,
current,
items,
selected: None,
scale: 1.0,
renaming: None,
rename_text: String::new(),
pending_open: None,
context_menu: None,
}
}
pub fn refresh(&mut self) {
self.items = model::scan_directory(&self.current);
}
pub fn update(&mut self, msg: BrowserMessage) {
match msg {
BrowserMessage::NavigateTo(path) => {
self.current = path;
self.selected = None;
self.renaming = None;
self.context_menu = None;
self.refresh();
}
BrowserMessage::Open(path) => {
self.pending_open = Some(path);
self.context_menu = None;
}
BrowserMessage::Select(path) => {
self.selected = Some(path);
self.context_menu = None;
}
BrowserMessage::StartRename(path) => {
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);
self.context_menu = None;
}
BrowserMessage::UpdateRename(text) => {
self.rename_text = text;
}
BrowserMessage::CommitRename => {
if let Some(path) = self.renaming.take() {
let is_file = path.is_file();
let _ = model::rename(&path, &self.rename_text, is_file);
self.rename_text.clear();
self.refresh();
}
}
BrowserMessage::CancelRename => {
self.renaming = None;
self.rename_text.clear();
}
BrowserMessage::Duplicate(path) => {
let _ = model::duplicate(&path);
self.context_menu = None;
self.refresh();
}
BrowserMessage::Trash(path) => {
let _ = model::trash(&path);
if self.selected.as_deref() == Some(&path) {
self.selected = None;
}
self.context_menu = None;
self.refresh();
}
BrowserMessage::NewFolder => {
let _ = model::create_folder(&self.current);
self.refresh();
}
BrowserMessage::ScaleUp => {
self.scale = (self.scale + 0.1).min(3.0);
}
BrowserMessage::ScaleDown => {
self.scale = (self.scale - 0.1).max(0.4);
}
BrowserMessage::Refresh => {
self.refresh();
}
BrowserMessage::ShowContextMenu { anchor, path, is_file } => {
self.context_menu = Some(ContextMenu { anchor, item_path: path, is_file });
}
BrowserMessage::HideContextMenu => {
self.context_menu = None;
}
}
}
pub fn take_pending_open(&mut self) -> Option<PathBuf> {
self.pending_open.take()
}
pub fn path_segments(&self) -> Vec<(String, PathBuf)> {
model::path_segments(&self.current, &self.root)
}
pub fn is_renaming(&self, item: &BrowserItem) -> bool {
self.renaming.as_deref() == Some(&item.path)
}
pub fn is_selected(&self, item: &BrowserItem) -> bool {
self.selected.as_deref() == Some(&item.path)
}
pub fn item_kind_is_file(item: &BrowserItem) -> bool {
item.kind == BrowserItemKind::File
}
}

237
viewport/src/browser/ui.rs Normal file
View File

@ -0,0 +1,237 @@
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 crate::palette;
use super::model::{BrowserItem, BrowserItemKind};
use super::state::{BrowserMessage, BrowserState};
const CARDS_PER_ROW: usize = 3;
const CARD_BASE_W: f32 = 240.0;
pub fn view(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
let body: Element<_, _, _> = if state.items.is_empty() {
empty_state()
} else {
scrollable(grid(state)).height(Length::Fill).into()
};
let main = column![
breadcrumb(state),
rule(p.surface1),
body,
]
.height(Length::Fill);
container(main)
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.base)),
border: Border::default(),
text_color: Some(p.text),
shadow: Default::default(),
snap: false,
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn breadcrumb(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
let segments = state.path_segments();
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(
text(">").size(11.0).color(p.overlay0).into()
);
}
let is_last = i == last_idx;
let label = text(name).size(12.0).color(if is_last { p.text } else { p.subtext0 });
let btn = button(label)
.padding(Padding { top: 2.0, right: 4.0, bottom: 2.0, left: 4.0 })
.style(move |_t: &Theme, _s| button::Style {
background: None,
text_color: if is_last { p.text } else { p.subtext0 },
border: Border::default(),
shadow: Default::default(),
snap: false,
})
.on_press(BrowserMessage::NavigateTo(path));
row_items.push(btn.into());
}
container(row(row_items).spacing(2.0))
.padding(Padding { top: 8.0, right: 16.0, bottom: 8.0, left: 16.0 })
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.mantle)),
border: Border::default(),
text_color: Some(p.text),
shadow: Default::default(),
snap: false,
})
.width(Length::Fill)
.into()
}
fn rule(color: Color) -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> {
container(text(""))
.width(Length::Fill)
.height(Length::Fixed(1.0))
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(color)),
border: Border::default(),
text_color: None,
shadow: Default::default(),
snap: false,
})
.into()
}
fn empty_state() -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
container(
column![
text("No documents").size(16.0).color(p.subtext0),
text("Create a new note or add files to this folder").size(12.0).color(p.overlay0),
]
.spacing(8.0)
)
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding { top: 100.0, right: 0.0, bottom: 0.0, left: 0.0 })
.center_x(Length::Fill)
.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;
for chunk in state.items.chunks(chunk_size) {
let mut row_items: Vec<Element<_, _, _>> = Vec::new();
for item in chunk {
row_items.push(card(item, state, scale));
}
// Pad short final row so cards keep their fixed width instead of stretching.
while row_items.len() < chunk_size {
row_items.push(
Space::new()
.width(Length::Fill)
.height(Length::Shrink)
.into()
);
}
rows.push(
row(row_items)
.spacing(16.0 * scale)
.into()
);
}
container(
column(rows)
.spacing(16.0 * scale)
.width(Length::Fill)
)
.padding(16.0 * scale)
.width(Length::Fill)
.into()
}
fn card<'a>(
item: &'a BrowserItem,
state: &'a BrowserState,
scale: 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 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(),
};
let title: Element<_, _, _> = if renaming {
text_input("Name", &state.rename_text)
.on_input(BrowserMessage::UpdateRename)
.on_submit(BrowserMessage::CommitRename)
.size(12.0 * scale)
.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()
};
let content = column![preview, title].spacing(6.0 * scale);
let item_path = item.path.clone();
let is_file = item.kind == BrowserItemKind::File;
let body = container(content)
.width(Length::Fixed(card_w))
.padding(10.0 * scale)
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(if selected { p.surface1 } else { p.surface0 })),
border: Border {
color: if selected { p.blue } else { Color::TRANSPARENT },
width: if selected { 2.0 } else { 0.0 },
radius: (8.0 * scale).into(),
},
text_color: Some(p.text),
shadow: Default::default(),
snap: false,
});
let click_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_right_press(BrowserMessage::ShowContextMenu {
anchor: iced_wgpu::core::Point::new(0.0, 0.0),
path: item_path,
is_file,
})
.into()
}

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ use std::ffi::{c_char, c_void, CStr, CString};
pub mod block; pub mod block;
pub mod blocks; pub mod blocks;
mod bridge; mod bridge;
pub mod browser;
mod editor; mod editor;
pub mod export; pub mod export;
mod handle; mod handle;

View File

@ -152,17 +152,34 @@ pub struct WidgetSurface {
pub border: Color, pub border: Color,
pub header_accent: Color, pub header_accent: Color,
pub body_text: Color, pub body_text: Color,
pub eval_fill: Color,
pub eval_border: Color,
pub eval_accent: Color,
} }
pub fn widget_surface() -> WidgetSurface { pub fn widget_surface() -> WidgetSurface {
let p = current(); let p = current();
// Dark: fill lifts above base (surface0) for a frosted-lighter card.
// Light: fill recedes below base (mantle) for a frosted-cooler card.
let fill = if is_dark() { p.surface0 } else { p.mantle }; let fill = if is_dark() { p.surface0 } else { p.mantle };
let eval_fill = if is_dark() { p.surface1 } else { p.crust };
WidgetSurface { WidgetSurface {
fill, fill,
border: p.surface2, border: p.surface2,
header_accent: p.teal, header_accent: p.teal,
body_text: p.text, body_text: p.text,
eval_fill,
eval_border: p.overlay0,
eval_accent: p.teal,
} }
} }
pub fn eval_value_color() -> Color {
if is_dark() {
Color::from_rgb(0.30, 0.95, 0.50)
} else {
Color::from_rgb(0.10, 0.55, 0.18)
}
}
pub fn eval_arrow_color() -> Color {
current().red
}

View File

@ -1170,21 +1170,22 @@ pub fn cell_id(block_id: u64, row: usize, col: usize) -> WidgetId {
WidgetId::from(format!("table_cell_{}_{}_{}", block_id, row, col)) WidgetId::from(format!("table_cell_{}_{}_{}", block_id, row, col))
} }
fn cell_border() -> Border { fn cell_border_for(is_eval: bool) -> Border {
let ws = palette::widget_surface(); let ws = palette::widget_surface();
Border { Border {
color: ws.border, color: if is_eval { ws.eval_border } else { ws.border },
width: 1.0, width: 1.0,
radius: 0.0.into(), radius: 0.0.into(),
} }
} }
fn cell_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { fn cell_input_style_for(is_eval: bool) -> text_input::Style {
let p = palette::current(); let p = palette::current();
let ws = palette::widget_surface(); let ws = palette::widget_surface();
let fill = if is_eval { ws.eval_fill } else { ws.fill };
text_input::Style { text_input::Style {
background: Background::Color(ws.fill), background: Background::Color(fill),
border: cell_border(), border: cell_border_for(is_eval),
icon: p.overlay2, icon: p.overlay2,
placeholder: p.overlay0, placeholder: p.overlay0,
value: ws.body_text, value: ws.body_text,
@ -1192,12 +1193,13 @@ fn cell_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::
} }
} }
fn header_cell_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { fn header_cell_style_for(is_eval: bool) -> text_input::Style {
let p = palette::current(); let p = palette::current();
let ws = palette::widget_surface(); let ws = palette::widget_surface();
let fill = if is_eval { ws.eval_fill } else { ws.fill };
text_input::Style { text_input::Style {
background: Background::Color(ws.fill), background: Background::Color(fill),
border: cell_border(), border: cell_border_for(is_eval),
icon: p.overlay2, icon: p.overlay2,
placeholder: p.overlay0, placeholder: p.overlay0,
value: ws.header_accent, value: ws.header_accent,
@ -1423,10 +1425,9 @@ where
{ {
// Edit mode (or eval-result table that the user can still // Edit mode (or eval-result table that the user can still
// copy from) — use the real text_input. // copy from) — use the real text_input.
let style_fn: fn(&Theme, text_input::Status) -> text_input::Style = if is_header { let is_eval = read_only;
header_cell_style let style_fn = move |_theme: &Theme, _status: text_input::Status| -> text_input::Style {
} else { if is_header { header_cell_style_for(is_eval) } else { cell_input_style_for(is_eval) }
cell_input_style
}; };
let mut input = text_input::TextInput::new("", cell) let mut input = text_input::TextInput::new("", cell)
.id(cell_id(block_id, ri, ci)) .id(cell_id(block_id, ri, ci))
@ -1474,20 +1475,19 @@ where
.color(oklab::lighten_for_size(label_color, font_size)) .color(oklab::lighten_for_size(label_color, font_size))
.wrapping(if block.wrap { Wrapping::Word } else { Wrapping::None }); .wrapping(if block.wrap { Wrapping::Word } else { Wrapping::None });
let is_eval = read_only;
let container_style = move |_theme: &Theme| { let container_style = move |_theme: &Theme| {
let ws = palette::widget_surface(); let ws = palette::widget_surface();
let p = palette::current(); let p = palette::current();
let surface_fill = if is_eval { ws.eval_fill } else { ws.fill };
let background = if is_focused_this { let background = if is_focused_this {
// Tinted blue background — Excel/Numbers selection look.
// Heavier alpha than the default tint so selection is
// unmistakably visible against the cell fill.
Some(Background::Color(Color { a: 0.45, ..p.blue })) Some(Background::Color(Color { a: 0.45, ..p.blue }))
} else { } else {
Some(Background::Color(ws.fill)) Some(Background::Color(surface_fill))
}; };
container::Style { container::Style {
background, background,
border: cell_border(), border: cell_border_for(is_eval),
text_color: Some(oklab::lighten_for_size(label_color, font_size)), text_color: Some(oklab::lighten_for_size(label_color, font_size)),
shadow: Shadow::default(), shadow: Shadow::default(),
snap: false, snap: false,
@ -1597,14 +1597,21 @@ where
let outer: Element<'a, Message, Theme, iced_wgpu::Renderer> = if read_only { let outer: Element<'a, Message, Theme, iced_wgpu::Renderer> = if read_only {
iced_widget::container(with_plus) iced_widget::container(with_plus)
.padding(Padding { top: 2.0, right: 0.0, bottom: 2.0, left: 8.0 }) .padding(Padding { top: 6.0, right: 6.0, bottom: 6.0, left: 12.0 })
.width(Length::Shrink) .width(Length::Shrink)
.style(|_theme: &Theme| container::Style { .style(|_theme: &Theme| {
background: None, let ws = palette::widget_surface();
border: Border::default(), container::Style {
text_color: None, background: Some(Background::Color(ws.eval_fill)),
shadow: Shadow::default(), border: Border {
snap: false, color: ws.eval_accent,
width: 0.0,
radius: 4.0.into(),
},
text_color: None,
shadow: Shadow::default(),
snap: false,
}
}) })
.into() .into()
} else { } else {

View File

@ -19,6 +19,7 @@ use acord_viewport::{
viewport_send_command, viewport_free_string, viewport_send_command, viewport_free_string,
ViewportHandle, ViewportHandle,
}; };
use acord_viewport::browser::{self, BrowserHandle};
use crate::config::Config; use crate::config::Config;
use crate::menu::{AppMenu, MenuAction}; use crate::menu::{AppMenu, MenuAction};
@ -34,6 +35,11 @@ pub struct App {
current_file: Option<PathBuf>, current_file: Option<PathBuf>,
last_autosave_attempt: Instant, last_autosave_attempt: Instant,
last_autosaved_hash: Option<u64>, last_autosaved_hash: Option<u64>,
browser_window: Option<Window>,
browser_handle: Option<BrowserHandle>,
browser_cursor: PhysicalPosition<f64>,
browser_scale: f32,
} }
impl App { impl App {
@ -49,6 +55,10 @@ impl App {
current_file: None, current_file: None,
last_autosave_attempt: Instant::now(), last_autosave_attempt: Instant::now(),
last_autosaved_hash: None, last_autosaved_hash: None,
browser_window: None,
browser_handle: None,
browser_cursor: PhysicalPosition::new(0.0, 0.0),
browser_scale: 1.0,
} }
} }
@ -106,9 +116,83 @@ impl App {
menu.set_auto_pair_check(bit, (new_flags & bit) != 0); menu.set_auto_pair_check(bit, (new_flags & bit) != 0);
} }
} }
MenuAction::ToggleBrowser => self.toggle_browser(event_loop),
} }
} }
fn toggle_browser(&mut self, event_loop: &ActiveEventLoop) {
if self.browser_window.is_some() {
self.close_browser();
} else {
self.open_browser(event_loop);
}
}
fn open_browser(&mut self, event_loop: &ActiveEventLoop) {
let mut attrs = WindowAttributes::default()
.with_title("Documents - Acord")
.with_inner_size(LogicalSize::new(900.0, 650.0));
if let Some(icon) = load_window_icon() {
attrs = attrs.with_window_icon(Some(icon));
}
let window = match event_loop.create_window(attrs) {
Ok(w) => w,
Err(_) => return,
};
self.browser_scale = window.scale_factor() as f32;
let size = window.inner_size();
let w = size.width as f32 / self.browser_scale;
let h = size.height as f32 / self.browser_scale;
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
let display = match window.display_handle() {
Ok(d) => d.as_raw(),
Err(_) => return,
};
let win_handle = match window.window_handle() {
Ok(w) => w.as_raw(),
Err(_) => return,
};
let notes_dir = self.config.notes_dir();
let _ = std::fs::create_dir_all(&notes_dir);
match browser::handle::create(display, win_handle, w, h, self.browser_scale, notes_dir) {
Some(handle) => {
self.browser_handle = Some(handle);
self.browser_window = Some(window);
}
None => {
drop(window);
}
}
}
fn close_browser(&mut self) {
self.browser_handle = None;
self.browser_window = None;
}
fn drain_browser_open(&mut self) {
let Some(handle) = self.browser_handle.as_mut() else { return };
let Some(path) = browser::handle::take_pending_open(handle) else { return };
if let Ok(text) = std::fs::read_to_string(&path) {
let c = CString::new(text).unwrap_or_default();
viewport_set_text(self.handle, c.as_ptr());
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md");
let c_ext = CString::new(ext).unwrap();
viewport_set_lang(self.handle, c_ext.as_ptr());
if let Some(w) = &self.window {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord");
w.set_title(&format!("{name} - Acord"));
w.focus_window();
}
self.current_file = Some(path);
self.last_autosaved_hash = None;
}
self.close_browser();
}
fn open_file(&mut self) { fn open_file(&mut self) {
let dialog = rfd::FileDialog::new() let dialog = rfd::FileDialog::new()
.add_filter("Markdown", &["md", "markdown"]) .add_filter("Markdown", &["md", "markdown"])
@ -208,6 +292,91 @@ impl App {
_ => 0, _ => 0,
} }
} }
fn handle_browser_event(&mut self, event: WindowEvent) {
let Some(handle) = self.browser_handle.as_mut() else { return };
match event {
WindowEvent::CloseRequested => {
self.close_browser();
}
WindowEvent::Resized(size) => {
let w = size.width as f32 / self.browser_scale;
let h = size.height as f32 / self.browser_scale;
browser::handle::resize(handle, w, h, self.browser_scale);
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.browser_scale = scale_factor as f32;
if let Some(win) = &self.browser_window {
let size = win.inner_size();
let w = size.width as f32 / self.browser_scale;
let h = size.height as f32 / self.browser_scale;
browser::handle::resize(handle, w, h, self.browser_scale);
}
}
WindowEvent::RedrawRequested => {
browser::handle::render(handle);
}
WindowEvent::CursorMoved { position, .. } => {
self.browser_cursor = position;
let x = position.x as f32 / self.browser_scale;
let y = position.y as f32 / self.browser_scale;
browser::handle::push_mouse_move(handle, x, y);
}
WindowEvent::MouseInput { state, button, .. } => {
let pressed = state == ElementState::Pressed;
browser::handle::push_mouse_button(handle, Self::winit_button(button), pressed);
}
WindowEvent::MouseWheel { delta, .. } => {
let (dx, dy) = match delta {
MouseScrollDelta::LineDelta(dx, dy) => (dx * 20.0, dy * 20.0),
MouseScrollDelta::PixelDelta(d) => (d.x as f32, d.y as f32),
};
browser::handle::push_scroll(handle, dx, -dy);
}
WindowEvent::KeyboardInput { event, .. } => {
use iced_wgpu::core::keyboard;
use iced_wgpu::core::Event as IcedEvent;
let pressed = event.state == ElementState::Pressed;
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()));
let physical_key = keyboard::key::Physical::Unidentified(keyboard::key::NativeCode::Unidentified);
let location = keyboard::Location::Standard;
let modified_key = key.clone();
let ev = if pressed {
keyboard::Event::KeyPressed {
key,
modified_key,
physical_key,
location,
modifiers,
text,
repeat: event.repeat,
}
} else {
keyboard::Event::KeyReleased {
key,
modified_key,
physical_key,
location,
modifiers,
}
};
browser::handle::push_event(handle, IcedEvent::Keyboard(ev));
}
WindowEvent::ModifiersChanged(mods) => {
self.modifiers = mods.state();
use iced_wgpu::core::keyboard;
use iced_wgpu::core::Event as IcedEvent;
browser::handle::push_event(
handle,
IcedEvent::Keyboard(keyboard::Event::ModifiersChanged(decode_winit_modifiers(mods.state()))),
);
}
_ => {}
}
}
} }
impl ApplicationHandler for App { impl ApplicationHandler for App {
@ -262,7 +431,13 @@ impl ApplicationHandler for App {
self.window = Some(window); self.window = Some(window);
} }
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
let is_browser = self.browser_window.as_ref().map(|w| w.id() == id).unwrap_or(false);
if is_browser {
self.handle_browser_event(event);
return;
}
if self.handle.is_null() { return; } if self.handle.is_null() { return; }
match event { match event {
@ -358,28 +533,22 @@ impl ApplicationHandler for App {
} }
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
// Poll menu events.
while let Some(action) = AppMenu::poll() { while let Some(action) = AppMenu::poll() {
self.dispatch_menu(action, _event_loop); self.dispatch_menu(action, _event_loop);
} }
// Hash-gated autosave on a 500ms cadence. The hash skip means
// an idle doc doesn't tick the disk; a typing doc writes once
// per cadence regardless of keystroke rate.
if self.last_autosave_attempt.elapsed() >= Duration::from_millis(500) { if self.last_autosave_attempt.elapsed() >= Duration::from_millis(500) {
self.last_autosave_attempt = Instant::now(); self.last_autosave_attempt = Instant::now();
self.try_autosave(); self.try_autosave();
} }
// Request a redraw if the viewport has pending work. self.drain_browser_open();
if let Some(w) = &self.window { if let Some(w) = &self.window {
if !self.handle.is_null() { if !self.handle.is_null() {
// Always request redraw — viewport_render short-circuits
// internally when idle (needs_redraw == false && no pending
// eval). Requesting unconditionally is simpler than reading
// the handle's state from here, and wgpu PresentMode::Fifo
// throttles to vsync anyway.
w.request_redraw(); w.request_redraw();
} }
} }
if let Some(w) = &self.browser_window {
w.request_redraw();
}
} }
} }
@ -390,6 +559,32 @@ fn text_hash(s: &str) -> u64 {
h.finish() h.finish()
} }
/// Map winit logical keys to iced keyboard keys for direct iced event push.
fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key {
use iced_wgpu::core::keyboard::{key as ikey, Key as IKey};
match key {
Key::Named(n) => match n {
NamedKey::Enter => IKey::Named(ikey::Named::Enter),
NamedKey::Tab => IKey::Named(ikey::Named::Tab),
NamedKey::Backspace => IKey::Named(ikey::Named::Backspace),
NamedKey::Escape => IKey::Named(ikey::Named::Escape),
NamedKey::Delete => IKey::Named(ikey::Named::Delete),
NamedKey::ArrowLeft => IKey::Named(ikey::Named::ArrowLeft),
NamedKey::ArrowRight => IKey::Named(ikey::Named::ArrowRight),
NamedKey::ArrowUp => IKey::Named(ikey::Named::ArrowUp),
NamedKey::ArrowDown => IKey::Named(ikey::Named::ArrowDown),
NamedKey::Home => IKey::Named(ikey::Named::Home),
NamedKey::End => IKey::Named(ikey::Named::End),
NamedKey::PageUp => IKey::Named(ikey::Named::PageUp),
NamedKey::PageDown => IKey::Named(ikey::Named::PageDown),
NamedKey::Space => IKey::Named(ikey::Named::Space),
_ => IKey::Unidentified,
},
Key::Character(s) => IKey::Character(iced_wgpu::core::SmolStr::new(s.as_str())),
_ => IKey::Unidentified,
}
}
/// Map winit logical keys to the macOS-style keycodes the bridge expects. /// Map winit logical keys to the macOS-style keycodes the bridge expects.
/// For Named keys, return the matching keycode. For character keys, the /// For Named keys, return the matching keycode. For character keys, the
/// bridge ignores the keycode and uses the text parameter directly, so /// bridge ignores the keycode and uses the text parameter directly, so

View File

@ -36,6 +36,7 @@ pub enum MenuAction {
Settings, Settings,
ExportCrate, ExportCrate,
ToggleAutoPair(u32), ToggleAutoPair(u32),
ToggleBrowser,
} }
impl AppMenu { impl AppMenu {
@ -45,6 +46,7 @@ impl AppMenu {
let file = Submenu::new("File", true); let file = Submenu::new("File", true);
file.append(&MenuItem::with_id("new", "New Note", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyN)))).ok(); file.append(&MenuItem::with_id("new", "New Note", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyN)))).ok();
file.append(&MenuItem::with_id("open", "Open...", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyO)))).ok(); file.append(&MenuItem::with_id("open", "Open...", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyO)))).ok();
file.append(&MenuItem::with_id("browse", "Documents...", true, Some(Accelerator::new(Some(Modifiers::ALT), Code::KeyB)))).ok();
file.append(&PredefinedMenuItem::separator()).ok(); file.append(&PredefinedMenuItem::separator()).ok();
file.append(&MenuItem::with_id("save", "Save", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyS)))).ok(); file.append(&MenuItem::with_id("save", "Save", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyS)))).ok();
file.append(&MenuItem::with_id("save_as", "Save As...", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyS)))).ok(); file.append(&MenuItem::with_id("save_as", "Save As...", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyS)))).ok();
@ -119,6 +121,7 @@ impl AppMenu {
match e.id().0.as_str() { match e.id().0.as_str() {
"new" => Some(MenuAction::NewNote), "new" => Some(MenuAction::NewNote),
"open" => Some(MenuAction::Open), "open" => Some(MenuAction::Open),
"browse" => Some(MenuAction::ToggleBrowser),
"save" => Some(MenuAction::Save), "save" => Some(MenuAction::Save),
"save_as" => Some(MenuAction::SaveAs), "save_as" => Some(MenuAction::SaveAs),
"quit" => Some(MenuAction::Quit), "quit" => Some(MenuAction::Quit),