Implemented Docs Browser for Windows and Linux
This commit is contained in:
parent
2fb2843caa
commit
8111a8164a
File diff suppressed because it is too large
Load Diff
198
linux/src/app.rs
198
linux/src/app.rs
|
|
@ -17,6 +17,7 @@ use acord_viewport::{
|
|||
viewport_send_command, viewport_free_string,
|
||||
ViewportHandle,
|
||||
};
|
||||
use acord_viewport::browser::{self, BrowserHandle};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::shortcuts::{match_shortcut, MenuAction};
|
||||
|
|
@ -31,6 +32,11 @@ pub struct App {
|
|||
current_file: Option<PathBuf>,
|
||||
last_autosave_attempt: Instant,
|
||||
last_autosaved_hash: Option<u64>,
|
||||
|
||||
browser_window: Option<Window>,
|
||||
browser_handle: Option<BrowserHandle>,
|
||||
browser_cursor: PhysicalPosition<f64>,
|
||||
browser_scale: f32,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -45,6 +51,10 @@ impl App {
|
|||
current_file: None,
|
||||
last_autosave_attempt: Instant::now(),
|
||||
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::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(¬es_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);
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
match event {
|
||||
|
|
@ -340,11 +504,15 @@ impl ApplicationHandler for App {
|
|||
self.last_autosave_attempt = Instant::now();
|
||||
self.try_autosave();
|
||||
}
|
||||
self.drain_browser_open();
|
||||
if let Some(w) = &self.window {
|
||||
if !self.handle.is_null() {
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// expects. Character keys go through `text` instead, so 0 is fine for those.
|
||||
fn winit_key_to_code(key: &Key) -> u32 {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub enum MenuAction {
|
|||
Find,
|
||||
Settings,
|
||||
ExportCrate,
|
||||
ToggleBrowser,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the Ctrl→LOGO modifier alias, plain typing, navigation, etc.).
|
||||
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() {
|
||||
return None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||
base64 = "0.22"
|
||||
arboard = "3"
|
||||
ureq = "3"
|
||||
trash = "5"
|
||||
|
||||
[build-dependencies]
|
||||
cbindgen = "0.29"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¤t);
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -3,6 +3,7 @@ use std::ffi::{c_char, c_void, CStr, CString};
|
|||
pub mod block;
|
||||
pub mod blocks;
|
||||
mod bridge;
|
||||
pub mod browser;
|
||||
mod editor;
|
||||
pub mod export;
|
||||
mod handle;
|
||||
|
|
|
|||
|
|
@ -152,17 +152,34 @@ pub struct WidgetSurface {
|
|||
pub border: Color,
|
||||
pub header_accent: Color,
|
||||
pub body_text: Color,
|
||||
pub eval_fill: Color,
|
||||
pub eval_border: Color,
|
||||
pub eval_accent: Color,
|
||||
}
|
||||
|
||||
pub fn widget_surface() -> WidgetSurface {
|
||||
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 eval_fill = if is_dark() { p.surface1 } else { p.crust };
|
||||
WidgetSurface {
|
||||
fill,
|
||||
border: p.surface2,
|
||||
header_accent: p.teal,
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
fn cell_border() -> Border {
|
||||
fn cell_border_for(is_eval: bool) -> Border {
|
||||
let ws = palette::widget_surface();
|
||||
Border {
|
||||
color: ws.border,
|
||||
color: if is_eval { ws.eval_border } else { ws.border },
|
||||
width: 1.0,
|
||||
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 ws = palette::widget_surface();
|
||||
let fill = if is_eval { ws.eval_fill } else { ws.fill };
|
||||
text_input::Style {
|
||||
background: Background::Color(ws.fill),
|
||||
border: cell_border(),
|
||||
background: Background::Color(fill),
|
||||
border: cell_border_for(is_eval),
|
||||
icon: p.overlay2,
|
||||
placeholder: p.overlay0,
|
||||
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 ws = palette::widget_surface();
|
||||
let fill = if is_eval { ws.eval_fill } else { ws.fill };
|
||||
text_input::Style {
|
||||
background: Background::Color(ws.fill),
|
||||
border: cell_border(),
|
||||
background: Background::Color(fill),
|
||||
border: cell_border_for(is_eval),
|
||||
icon: p.overlay2,
|
||||
placeholder: p.overlay0,
|
||||
value: ws.header_accent,
|
||||
|
|
@ -1423,10 +1425,9 @@ where
|
|||
{
|
||||
// Edit mode (or eval-result table that the user can still
|
||||
// copy from) — use the real text_input.
|
||||
let style_fn: fn(&Theme, text_input::Status) -> text_input::Style = if is_header {
|
||||
header_cell_style
|
||||
} else {
|
||||
cell_input_style
|
||||
let is_eval = read_only;
|
||||
let style_fn = move |_theme: &Theme, _status: text_input::Status| -> text_input::Style {
|
||||
if is_header { header_cell_style_for(is_eval) } else { cell_input_style_for(is_eval) }
|
||||
};
|
||||
let mut input = text_input::TextInput::new("", cell)
|
||||
.id(cell_id(block_id, ri, ci))
|
||||
|
|
@ -1474,20 +1475,19 @@ where
|
|||
.color(oklab::lighten_for_size(label_color, font_size))
|
||||
.wrapping(if block.wrap { Wrapping::Word } else { Wrapping::None });
|
||||
|
||||
let is_eval = read_only;
|
||||
let container_style = move |_theme: &Theme| {
|
||||
let ws = palette::widget_surface();
|
||||
let p = palette::current();
|
||||
let surface_fill = if is_eval { ws.eval_fill } else { ws.fill };
|
||||
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 }))
|
||||
} else {
|
||||
Some(Background::Color(ws.fill))
|
||||
Some(Background::Color(surface_fill))
|
||||
};
|
||||
container::Style {
|
||||
background,
|
||||
border: cell_border(),
|
||||
border: cell_border_for(is_eval),
|
||||
text_color: Some(oklab::lighten_for_size(label_color, font_size)),
|
||||
shadow: Shadow::default(),
|
||||
snap: false,
|
||||
|
|
@ -1597,14 +1597,21 @@ where
|
|||
|
||||
let outer: Element<'a, Message, Theme, iced_wgpu::Renderer> = if read_only {
|
||||
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)
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: None,
|
||||
border: Border::default(),
|
||||
.style(|_theme: &Theme| {
|
||||
let ws = palette::widget_surface();
|
||||
container::Style {
|
||||
background: Some(Background::Color(ws.eval_fill)),
|
||||
border: Border {
|
||||
color: ws.eval_accent,
|
||||
width: 0.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
text_color: None,
|
||||
shadow: Shadow::default(),
|
||||
snap: false,
|
||||
}
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use acord_viewport::{
|
|||
viewport_send_command, viewport_free_string,
|
||||
ViewportHandle,
|
||||
};
|
||||
use acord_viewport::browser::{self, BrowserHandle};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::menu::{AppMenu, MenuAction};
|
||||
|
|
@ -34,6 +35,11 @@ pub struct App {
|
|||
current_file: Option<PathBuf>,
|
||||
last_autosave_attempt: Instant,
|
||||
last_autosaved_hash: Option<u64>,
|
||||
|
||||
browser_window: Option<Window>,
|
||||
browser_handle: Option<BrowserHandle>,
|
||||
browser_cursor: PhysicalPosition<f64>,
|
||||
browser_scale: f32,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -49,6 +55,10 @@ impl App {
|
|||
current_file: None,
|
||||
last_autosave_attempt: Instant::now(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
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(¬es_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) {
|
||||
let dialog = rfd::FileDialog::new()
|
||||
.add_filter("Markdown", &["md", "markdown"])
|
||||
|
|
@ -208,6 +292,91 @@ impl App {
|
|||
_ => 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 {
|
||||
|
|
@ -262,7 +431,13 @@ impl ApplicationHandler for App {
|
|||
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; }
|
||||
|
||||
match event {
|
||||
|
|
@ -358,28 +533,22 @@ impl ApplicationHandler for App {
|
|||
}
|
||||
|
||||
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
// Poll menu events.
|
||||
while let Some(action) = AppMenu::poll() {
|
||||
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) {
|
||||
self.last_autosave_attempt = Instant::now();
|
||||
self.try_autosave();
|
||||
}
|
||||
// Request a redraw if the viewport has pending work.
|
||||
self.drain_browser_open();
|
||||
if let Some(w) = &self.window {
|
||||
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();
|
||||
}
|
||||
}
|
||||
if let Some(w) = &self.browser_window {
|
||||
w.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -390,6 +559,32 @@ fn text_hash(s: &str) -> u64 {
|
|||
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.
|
||||
/// For Named keys, return the matching keycode. For character keys, the
|
||||
/// bridge ignores the keycode and uses the text parameter directly, so
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub enum MenuAction {
|
|||
Settings,
|
||||
ExportCrate,
|
||||
ToggleAutoPair(u32),
|
||||
ToggleBrowser,
|
||||
}
|
||||
|
||||
impl AppMenu {
|
||||
|
|
@ -45,6 +46,7 @@ impl AppMenu {
|
|||
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("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(&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();
|
||||
|
|
@ -119,6 +121,7 @@ impl AppMenu {
|
|||
match e.id().0.as_str() {
|
||||
"new" => Some(MenuAction::NewNote),
|
||||
"open" => Some(MenuAction::Open),
|
||||
"browse" => Some(MenuAction::ToggleBrowser),
|
||||
"save" => Some(MenuAction::Save),
|
||||
"save_as" => Some(MenuAction::SaveAs),
|
||||
"quit" => Some(MenuAction::Quit),
|
||||
|
|
|
|||
Loading…
Reference in New Issue