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.
This commit is contained in:
jess 2026-05-01 14:01:37 -07:00
parent 21c2aa8e95
commit 6cfb229132
8 changed files with 250 additions and 33 deletions

View File

@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
use winit::application::ApplicationHandler; use winit::application::ApplicationHandler;
use winit::dpi::{LogicalSize, PhysicalPosition}; use winit::dpi::{LogicalSize, PhysicalPosition};
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; 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::keyboard::{Key, ModifiersState, NamedKey};
use winit::window::{Window, WindowAttributes, WindowId}; 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) { 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();
} }
self.drain_shell_actions(_event_loop); self.drain_shell_actions(event_loop);
self.drain_browser_open(); 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() {
@ -608,6 +608,12 @@ impl ApplicationHandler for App {
if let Some(w) = &self.browser_window { if let Some(w) = &self.browser_window {
w.request_redraw(); 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),
));
} }
} }

View File

@ -51,9 +51,19 @@ impl Config {
} }
pub fn notes_dir(&self) -> PathBuf { pub fn notes_dir(&self) -> PathBuf {
self.data.get("autoSaveDirectory") let default = config_dir().join("notes");
.map(PathBuf::from) match self.data.get("autoSaveDirectory") {
.unwrap_or_else(|| config_dir().join("notes")) 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 { pub fn auto_pair_flags(&self) -> u32 {

View File

@ -27,6 +27,7 @@ base64 = "0.22"
arboard = "3" arboard = "3"
ureq = "3" ureq = "3"
trash = "5" trash = "5"
filetime = "0.2"
[build-dependencies] [build-dependencies]
cbindgen = "0.29" cbindgen = "0.29"

View File

@ -255,20 +255,24 @@ pub fn reparse_incremental(old_blocks: &mut Vec<BoxedBlock>, text: &str, lang: &
.collect(); .collect();
if old_descriptors == new_descriptors { if old_descriptors == new_descriptors {
// Same structure: update text content in place for text blocks; align // Same structure: update text content in place for text blocks; for
// start_line for all kinds. Cursor preserved (Content not recreated // non-text kinds, rebuild from the span only when the serialized form
// unless serialized text actually changed). // 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()) { for (block, span) in old_blocks.iter_mut().zip(spans.iter()) {
block.set_start_line(span.start); block.set_start_line(span.start);
let block_text = lines[span.start..span.end].join("\n");
if matches!(span.kind, SpanKind::Text) { if matches!(span.kind, SpanKind::Text) {
if let Some(tb) = block.as_any_mut().downcast_mut::<TextBlock>() { if let Some(tb) = block.as_any_mut().downcast_mut::<TextBlock>() {
let block_text = lines[span.start..span.end].join("\n"); if tb.content.text() != block_text {
let current = tb.content.text();
if current != block_text {
tb.content = tb.content =
crate::text_widget::Content::with_text(&block_text); crate::text_widget::Content::with_text(&block_text);
} }
} }
} else if block.to_md() != block_text {
*block = build_block(span, &lines, lang);
} }
} }
return; return;
@ -289,16 +293,20 @@ pub fn reparse_incremental(old_blocks: &mut Vec<BoxedBlock>, text: &str, lang: &
if reuse { if reuse {
let mut b = std::mem::replace(&mut old_blocks[i], placeholder()); let mut b = std::mem::replace(&mut old_blocks[i], placeholder());
b.set_start_line(span.start); b.set_start_line(span.start);
let block_text = lines[span.start..span.end].join("\n");
if matches!(span.kind, SpanKind::Text) { if matches!(span.kind, SpanKind::Text) {
if let Some(tb) = b.as_any_mut().downcast_mut::<TextBlock>() { if let Some(tb) = b.as_any_mut().downcast_mut::<TextBlock>() {
let block_text = lines[span.start..span.end].join("\n");
if tb.content.text() != block_text { if tb.content.text() != block_text {
tb.content = tb.content =
crate::text_widget::Content::with_text(&block_text); 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 { } else {
new_blocks.push(build_block(span, &lines, lang)); new_blocks.push(build_block(span, &lines, lang));
} }

View File

@ -204,6 +204,50 @@ pub fn move_into(item_path: &Path, folder: &Path) -> std::io::Result<PathBuf> {
Ok(dest) 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<PathBuf> { pub fn create_folder(parent: &Path) -> std::io::Result<PathBuf> {
let mut name = "New Folder".to_string(); let mut name = "New Folder".to_string();
let mut n = 1usize; let mut n = 1usize;

View File

@ -18,10 +18,36 @@ pub struct BrowserState {
/// holds the next path the host shell should open; drained each frame. /// holds the next path the host shell should open; drained each frame.
pub pending_open: Option<PathBuf>, pub pending_open: Option<PathBuf>,
pub context_menu: Option<ContextMenu>, pub context_menu: Option<ContextMenu>,
pub drag: Option<DragState>,
pub current_modifiers: Modifiers, pub current_modifiers: Modifiers,
pub cursor_pos: Point, 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<PathBuf>,
pub start: Point,
pub current: Point,
/// false until the cursor has moved past DRAG_THRESHOLD_PX from start.
pub active: bool,
pub hover: Option<DragHover>,
}
#[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)] #[derive(Debug, Clone)]
pub struct ContextMenu { pub struct ContextMenu {
pub anchor: Point, pub anchor: Point,
@ -59,6 +85,11 @@ pub enum BrowserMessage {
ContextRename, ContextRename,
ContextDuplicate, ContextDuplicate,
ContextTrash, ContextTrash,
DragMove(Point),
DragEnd,
DragCancel,
CardHoverEnter { path: PathBuf, is_folder: bool },
CardHoverExit(PathBuf),
} }
impl BrowserState { impl BrowserState {
@ -76,6 +107,7 @@ impl BrowserState {
rename_text: String::new(), rename_text: String::new(),
pending_open: None, pending_open: None,
context_menu: None, context_menu: None,
drag: None,
current_modifiers: Modifiers::empty(), current_modifiers: Modifiers::empty(),
cursor_pos: Point::ORIGIN, cursor_pos: Point::ORIGIN,
} }
@ -101,8 +133,25 @@ impl BrowserState {
self.context_menu = None; self.context_menu = None;
} }
BrowserMessage::Select(path) => { BrowserMessage::Select(path) => {
self.apply_selection(path);
self.context_menu = None; 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<PathBuf> = 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) => { BrowserMessage::StartRename(path) => {
let stem = path let stem = path
@ -233,6 +282,53 @@ impl BrowserState {
} }
self.refresh(); 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) 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 { pub fn item_kind_is_file(item: &BrowserItem) -> bool {
item.kind == BrowserItemKind::File item.kind == BrowserItemKind::File
} }

View File

@ -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() responsive(|size| scrollable(grid(state, size)).height(Length::Fill).into()).into()
}; };
// Captures right-clicks that fall between cards. Cards have their own // Captures right-clicks between cards plus drag motion/release that
// on_right_press, so this only fires on the gaps and empty regions. // 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) let body: Element<_, _, _> = mouse_area(body_inner)
.on_right_press(BrowserMessage::ShowEmptyContextMenu) .on_right_press(BrowserMessage::ShowEmptyContextMenu)
.on_move(BrowserMessage::DragMove)
.on_release(BrowserMessage::DragEnd)
.into(); .into();
let main: Element<_, _, _> = column![ let main: Element<_, _, _> = column![
@ -207,6 +210,8 @@ fn card<'a>(
let p = palette::current(); let p = palette::current();
let selected = state.is_selected(item); let selected = state.is_selected(item);
let renaming = state.is_renaming(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_size = 12.0 * scale;
let title_h = title_size * 1.4 + 4.0; let title_h = title_size * 1.4 + 4.0;
@ -247,16 +252,30 @@ fn card<'a>(
.height(Length::Fixed(card_h)) .height(Length::Fixed(card_h))
.padding(CARD_PAD) .padding(CARD_PAD)
.clip(true) .clip(true)
.style(move |_t: &Theme| container::Style { .style(move |_t: &Theme| {
background: Some(Background::Color(if selected { p.surface1 } else { p.surface0 })), let (border_color, border_width, bg) = if drop_target {
border: Border { (p.green, 2.0, p.surface1)
color: if selected { p.blue } else { Color::TRANSPARENT }, } else if selected {
width: if selected { 2.0 } else { 0.0 }, (p.blue, 2.0, p.surface1)
radius: (8.0 * scale).into(), } else {
}, (p.surface2, 1.0, p.surface0)
text_color: Some(p.text), };
shadow: Default::default(), let bg = if dragging {
snap: false, 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 { let open_msg = match item.kind {
@ -264,13 +283,16 @@ fn card<'a>(
BrowserItemKind::File => BrowserMessage::Open(item_path.clone()), BrowserItemKind::File => BrowserMessage::Open(item_path.clone()),
}; };
let is_folder = !is_file;
mouse_area(body) mouse_area(body)
.on_press(BrowserMessage::Select(item_path.clone())) .on_press(BrowserMessage::Select(item_path.clone()))
.on_double_click(open_msg) .on_double_click(open_msg)
.on_right_press(BrowserMessage::ShowContextMenu { .on_right_press(BrowserMessage::ShowContextMenu {
path: item_path, path: item_path.clone(),
is_file, is_file,
}) })
.on_enter(BrowserMessage::CardHoverEnter { path: item_path.clone(), is_folder })
.on_exit(BrowserMessage::CardHoverExit(item_path))
.into() .into()
} }
@ -462,7 +484,7 @@ fn menu_column<'a>(
.style(move |_t: &Theme| container::Style { .style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.surface1)), background: Some(Background::Color(p.surface1)),
border: Border { border: Border {
color: p.surface2, color: p.overlay1,
width: 1.0, width: 1.0,
radius: 6.0.into(), 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))) 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 }) .padding(Padding { top: 4.0, right: 6.0, bottom: 4.0, left: 6.0 })
.style(move |_t: &Theme| container::Style { .style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.surface2)), background: Some(Background::Color(p.overlay0)),
border: Border::default(), border: Border::default(),
text_color: None, text_color: None,
shadow: Default::default(), shadow: Default::default(),

View File

@ -52,9 +52,19 @@ impl Config {
} }
pub fn notes_dir(&self) -> PathBuf { pub fn notes_dir(&self) -> PathBuf {
self.data.get("autoSaveDirectory") let default = config_dir().join("notes");
.map(PathBuf::from) match self.data.get("autoSaveDirectory") {
.unwrap_or_else(|| config_dir().join("notes")) 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 { pub fn auto_pair_flags(&self) -> u32 {