From 6cfb229132c22dcfad5081bd600f69bd77ebe6d3 Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 1 May 2026 14:01:37 -0700 Subject: [PATCH] Fixed a couple of bugs I hadn't been able to wrap my mind around until suddenly - I knew. - Title block misappropriation/reappropriation. - Different related bug where the last opened note would get renamed the previous note's title, causing it to duplicate to avoid overwriting, but combined with bug 1 and 2 you ended up with 3 notes all with different titles none of which matched their title blocks. Oh, and expanded on the document browser's context menu and other items required to finish the folder organization within the browser. --- linux/src/app.rs | 12 +++- linux/src/config.rs | 16 ++++- viewport/Cargo.toml | 1 + viewport/src/blocks.rs | 24 ++++--- viewport/src/browser/model.rs | 44 +++++++++++++ viewport/src/browser/state.rs | 118 +++++++++++++++++++++++++++++++++- viewport/src/browser/ui.rs | 52 ++++++++++----- windows/src/config.rs | 16 ++++- 8 files changed, 250 insertions(+), 33 deletions(-) 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 {