diff --git a/linux/src/app.rs b/linux/src/app.rs index aaf88c1..2832aba 100644 --- a/linux/src/app.rs +++ b/linux/src/app.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::dpi::{LogicalSize, PhysicalPosition}; use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; -use winit::event_loop::ActiveEventLoop; +use winit::event_loop::{ActiveEventLoop, ControlFlow}; use winit::keyboard::{Key, ModifiersState, NamedKey}; use winit::window::{Window, WindowAttributes, WindowId}; @@ -593,12 +593,12 @@ impl ApplicationHandler for App { } } - fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { if self.last_autosave_attempt.elapsed() >= Duration::from_millis(500) { self.last_autosave_attempt = Instant::now(); self.try_autosave(); } - self.drain_shell_actions(_event_loop); + self.drain_shell_actions(event_loop); self.drain_browser_open(); if let Some(w) = &self.window { if !self.handle.is_null() { @@ -608,6 +608,12 @@ impl ApplicationHandler for App { if let Some(w) = &self.browser_window { w.request_redraw(); } + // request_redraw alone doesn't reliably wake the loop on Wayland + // when idle; pin a hard wake-up so the autosave check actually + // runs even with no input or compositor frame callback arriving. + event_loop.set_control_flow(ControlFlow::WaitUntil( + Instant::now() + Duration::from_millis(500), + )); } } diff --git a/linux/src/config.rs b/linux/src/config.rs index dd53870..1229d88 100644 --- a/linux/src/config.rs +++ b/linux/src/config.rs @@ -51,9 +51,19 @@ impl Config { } pub fn notes_dir(&self) -> PathBuf { - self.data.get("autoSaveDirectory") - .map(PathBuf::from) - .unwrap_or_else(|| config_dir().join("notes")) + let default = config_dir().join("notes"); + match self.data.get("autoSaveDirectory") { + Some(s) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + default + } else { + let p = PathBuf::from(trimmed); + if p.is_dir() { p } else { default } + } + } + None => default, + } } pub fn auto_pair_flags(&self) -> u32 { diff --git a/viewport/Cargo.toml b/viewport/Cargo.toml index 263d363..52a6dd5 100644 --- a/viewport/Cargo.toml +++ b/viewport/Cargo.toml @@ -27,6 +27,7 @@ base64 = "0.22" arboard = "3" ureq = "3" trash = "5" +filetime = "0.2" [build-dependencies] cbindgen = "0.29" diff --git a/viewport/src/blocks.rs b/viewport/src/blocks.rs index 6317ce9..413debe 100644 --- a/viewport/src/blocks.rs +++ b/viewport/src/blocks.rs @@ -255,20 +255,24 @@ pub fn reparse_incremental(old_blocks: &mut Vec, text: &str, lang: & .collect(); if old_descriptors == new_descriptors { - // Same structure: update text content in place for text blocks; align - // start_line for all kinds. Cursor preserved (Content not recreated - // unless serialized text actually changed). + // Same structure: update text content in place for text blocks; for + // non-text kinds, rebuild from the span only when the serialized form + // actually changed — preserves heading/table/tree internal state on + // typical edits while still picking up content swaps (e.g. opening + // a note whose first heading lives at the same line as the previous + // note's heading). for (block, span) in old_blocks.iter_mut().zip(spans.iter()) { block.set_start_line(span.start); + let block_text = lines[span.start..span.end].join("\n"); if matches!(span.kind, SpanKind::Text) { if let Some(tb) = block.as_any_mut().downcast_mut::() { - let block_text = lines[span.start..span.end].join("\n"); - let current = tb.content.text(); - if current != block_text { + if tb.content.text() != block_text { tb.content = crate::text_widget::Content::with_text(&block_text); } } + } else if block.to_md() != block_text { + *block = build_block(span, &lines, lang); } } return; @@ -289,16 +293,20 @@ pub fn reparse_incremental(old_blocks: &mut Vec, text: &str, lang: & if reuse { let mut b = std::mem::replace(&mut old_blocks[i], placeholder()); b.set_start_line(span.start); + let block_text = lines[span.start..span.end].join("\n"); if matches!(span.kind, SpanKind::Text) { if let Some(tb) = b.as_any_mut().downcast_mut::() { - let block_text = lines[span.start..span.end].join("\n"); if tb.content.text() != block_text { tb.content = crate::text_widget::Content::with_text(&block_text); } } + new_blocks.push(b); + } else if b.to_md() != block_text { + new_blocks.push(build_block(span, &lines, lang)); + } else { + new_blocks.push(b); } - new_blocks.push(b); } else { new_blocks.push(build_block(span, &lines, lang)); } diff --git a/viewport/src/browser/model.rs b/viewport/src/browser/model.rs index de8e4ae..004f30c 100644 --- a/viewport/src/browser/model.rs +++ b/viewport/src/browser/model.rs @@ -204,6 +204,50 @@ pub fn move_into(item_path: &Path, folder: &Path) -> std::io::Result { Ok(dest) } +/// Bumps each path's mtime so it sorts immediately above `anchor` in date-descending order. +/// Items keep their relative order: the first path in `items` lands closest above the anchor. +pub fn reorder_before(items: &[PathBuf], anchor: &Path) -> std::io::Result<()> { + let anchor_meta = std::fs::metadata(anchor)?; + let anchor_mtime = anchor_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); + // Spread the dragged items across one second of mtime above the anchor. + // The earliest in `items` gets the highest mtime so it sorts first under + // descending order — matches the user's drag-order expectation. + let n = items.len(); + if n == 0 { return Ok(()); } + let step_ms: u64 = (1000 / n.max(1) as u64).max(1); + for (i, path) in items.iter().enumerate() { + let offset_ms = (n - i) as u64 * step_ms; + let new_time = anchor_mtime + std::time::Duration::from_millis(offset_ms); + let ft = filetime::FileTime::from_system_time(new_time); + let _ = filetime::set_file_mtime(path, ft); + } + Ok(()) +} + +/// Bumps each path's mtime above every existing item in `parent`, preserving drag order. +/// Used when dropping items at the very top of the grid. +pub fn reorder_to_top(items: &[PathBuf], parent: &Path) -> std::io::Result<()> { + let mut max_mtime = SystemTime::UNIX_EPOCH; + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + if let Ok(t) = meta.modified() { + if t > max_mtime { max_mtime = t; } + } + } + } + } + let base = max_mtime.max(SystemTime::now() - std::time::Duration::from_secs(1)); + let n = items.len(); + if n == 0 { return Ok(()); } + for (i, path) in items.iter().enumerate() { + let new_time = base + std::time::Duration::from_millis((n - i) as u64 * 10 + 100); + let ft = filetime::FileTime::from_system_time(new_time); + let _ = filetime::set_file_mtime(path, ft); + } + Ok(()) +} + pub fn create_folder(parent: &Path) -> std::io::Result { let mut name = "New Folder".to_string(); let mut n = 1usize; diff --git a/viewport/src/browser/state.rs b/viewport/src/browser/state.rs index 8112743..2fe1472 100644 --- a/viewport/src/browser/state.rs +++ b/viewport/src/browser/state.rs @@ -18,10 +18,36 @@ pub struct BrowserState { /// holds the next path the host shell should open; drained each frame. pub pending_open: Option, pub context_menu: Option, + pub drag: Option, pub current_modifiers: Modifiers, pub cursor_pos: Point, } +/// pixels the cursor has to move past the press point before a drag activates. +const DRAG_THRESHOLD_PX: f32 = 4.0; + +#[derive(Debug, Clone)] +pub struct DragState { + pub items: Vec, + pub start: Point, + pub current: Point, + /// false until the cursor has moved past DRAG_THRESHOLD_PX from start. + pub active: bool, + pub hover: Option, +} + +#[derive(Debug, Clone)] +pub struct DragHover { + pub path: PathBuf, + pub is_folder: bool, +} + +impl DragState { + fn dragging_path(&self, p: &PathBuf) -> bool { + self.items.iter().any(|i| i == p) + } +} + #[derive(Debug, Clone)] pub struct ContextMenu { pub anchor: Point, @@ -59,6 +85,11 @@ pub enum BrowserMessage { ContextRename, ContextDuplicate, ContextTrash, + DragMove(Point), + DragEnd, + DragCancel, + CardHoverEnter { path: PathBuf, is_folder: bool }, + CardHoverExit(PathBuf), } impl BrowserState { @@ -76,6 +107,7 @@ impl BrowserState { rename_text: String::new(), pending_open: None, context_menu: None, + drag: None, current_modifiers: Modifiers::empty(), cursor_pos: Point::ORIGIN, } @@ -101,8 +133,25 @@ impl BrowserState { self.context_menu = None; } BrowserMessage::Select(path) => { - self.apply_selection(path); self.context_menu = None; + let mods = self.current_modifiers; + let plain_press = !mods.command() && !mods.shift(); + let already_selected = self.selected.contains(&path) && self.selected.len() >= 1; + if plain_press && already_selected && self.renaming.is_none() { + // Path is already part of the selection; arm a drag instead + // of collapsing the selection to just this one card. + let items: Vec = self.selected.iter().cloned().collect(); + self.drag = Some(DragState { + items, + start: self.cursor_pos, + current: self.cursor_pos, + active: false, + hover: None, + }); + } else { + self.apply_selection(path); + self.drag = None; + } } BrowserMessage::StartRename(path) => { let stem = path @@ -233,6 +282,53 @@ impl BrowserState { } self.refresh(); } + BrowserMessage::DragMove(point) => { + if let Some(drag) = self.drag.as_mut() { + drag.current = point; + if !drag.active { + let dx = point.x - drag.start.x; + let dy = point.y - drag.start.y; + if (dx * dx + dy * dy).sqrt() > DRAG_THRESHOLD_PX { + drag.active = true; + } + } + } + } + BrowserMessage::DragEnd => { + let Some(drag) = self.drag.take() else { return }; + if !drag.active { + return; + } + let Some(hover) = drag.hover.clone() else { return }; + if drag.dragging_path(&hover.path) { + return; + } + if hover.is_folder { + for path in &drag.items { + let _ = model::move_into(path, &hover.path); + } + self.selected.clear(); + self.selection_anchor = None; + } else { + let _ = model::reorder_before(&drag.items, &hover.path); + } + self.refresh(); + } + BrowserMessage::DragCancel => { + self.drag = None; + } + BrowserMessage::CardHoverEnter { path, is_folder } => { + if let Some(drag) = self.drag.as_mut() { + drag.hover = Some(DragHover { path, is_folder }); + } + } + BrowserMessage::CardHoverExit(path) => { + if let Some(drag) = self.drag.as_mut() { + if drag.hover.as_ref().map(|h| &h.path) == Some(&path) { + drag.hover = None; + } + } + } } } @@ -252,6 +348,26 @@ impl BrowserState { self.selected.contains(&item.path) } + /// True when `item` is part of an active (cursor moved past threshold) drag. + pub fn is_dragging(&self, item: &BrowserItem) -> bool { + match self.drag.as_ref() { + Some(d) if d.active => d.items.iter().any(|p| p == &item.path), + _ => false, + } + } + + /// True when `item` is the currently hovered drop target during an active drag, + /// and dropping there would actually do something (target isn't part of the drag). + pub fn is_drop_target(&self, item: &BrowserItem) -> bool { + match self.drag.as_ref() { + Some(d) if d.active => match &d.hover { + Some(h) => h.path == item.path && !d.dragging_path(&h.path), + None => false, + }, + _ => false, + } + } + pub fn item_kind_is_file(item: &BrowserItem) -> bool { item.kind == BrowserItemKind::File } diff --git a/viewport/src/browser/ui.rs b/viewport/src/browser/ui.rs index 8f870c4..199777b 100644 --- a/viewport/src/browser/ui.rs +++ b/viewport/src/browser/ui.rs @@ -28,10 +28,13 @@ pub fn view(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgp responsive(|size| scrollable(grid(state, size)).height(Length::Fill).into()).into() }; - // Captures right-clicks that fall between cards. Cards have their own - // on_right_press, so this only fires on the gaps and empty regions. + // Captures right-clicks between cards plus drag motion/release that + // happens off-card. Cards have their own on_right_press / on_press, so + // this body-level mouse_area only sees the gaps for those. let body: Element<_, _, _> = mouse_area(body_inner) .on_right_press(BrowserMessage::ShowEmptyContextMenu) + .on_move(BrowserMessage::DragMove) + .on_release(BrowserMessage::DragEnd) .into(); let main: Element<_, _, _> = column![ @@ -207,6 +210,8 @@ fn card<'a>( let p = palette::current(); let selected = state.is_selected(item); let renaming = state.is_renaming(item); + let dragging = state.is_dragging(item); + let drop_target = state.is_drop_target(item); let title_size = 12.0 * scale; let title_h = title_size * 1.4 + 4.0; @@ -247,16 +252,30 @@ fn card<'a>( .height(Length::Fixed(card_h)) .padding(CARD_PAD) .clip(true) - .style(move |_t: &Theme| container::Style { - background: Some(Background::Color(if selected { p.surface1 } else { p.surface0 })), - border: Border { - 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, + .style(move |_t: &Theme| { + let (border_color, border_width, bg) = if drop_target { + (p.green, 2.0, p.surface1) + } else if selected { + (p.blue, 2.0, p.surface1) + } else { + (p.surface2, 1.0, p.surface0) + }; + let bg = if dragging { + Color { a: 0.5, ..bg } + } else { + bg + }; + container::Style { + background: Some(Background::Color(bg)), + border: Border { + color: border_color, + width: border_width, + radius: (8.0 * scale).into(), + }, + text_color: Some(p.text), + shadow: Default::default(), + snap: false, + } }); let open_msg = match item.kind { @@ -264,13 +283,16 @@ fn card<'a>( BrowserItemKind::File => BrowserMessage::Open(item_path.clone()), }; + let is_folder = !is_file; mouse_area(body) .on_press(BrowserMessage::Select(item_path.clone())) .on_double_click(open_msg) .on_right_press(BrowserMessage::ShowContextMenu { - path: item_path, + path: item_path.clone(), is_file, }) + .on_enter(BrowserMessage::CardHoverEnter { path: item_path.clone(), is_folder }) + .on_exit(BrowserMessage::CardHoverExit(item_path)) .into() } @@ -462,7 +484,7 @@ fn menu_column<'a>( .style(move |_t: &Theme| container::Style { background: Some(Background::Color(p.surface1)), border: Border { - color: p.surface2, + color: p.overlay1, width: 1.0, radius: 6.0.into(), }, @@ -505,7 +527,7 @@ fn menu_separator() -> Element<'static, BrowserMessage, Theme, iced_wgpu::Render container(Space::new().width(Length::Fill).height(Length::Fixed(1.0))) .padding(Padding { top: 4.0, right: 6.0, bottom: 4.0, left: 6.0 }) .style(move |_t: &Theme| container::Style { - background: Some(Background::Color(p.surface2)), + background: Some(Background::Color(p.overlay0)), border: Border::default(), text_color: None, shadow: Default::default(), diff --git a/windows/src/config.rs b/windows/src/config.rs index 223a201..7a03426 100644 --- a/windows/src/config.rs +++ b/windows/src/config.rs @@ -52,9 +52,19 @@ impl Config { } pub fn notes_dir(&self) -> PathBuf { - self.data.get("autoSaveDirectory") - .map(PathBuf::from) - .unwrap_or_else(|| config_dir().join("notes")) + let default = config_dir().join("notes"); + match self.data.get("autoSaveDirectory") { + Some(s) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + default + } else { + let p = PathBuf::from(trimmed); + if p.is_dir() { p } else { default } + } + } + None => default, + } } pub fn auto_pair_flags(&self) -> u32 {