diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e6929269..ac206240 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1670,7 +1670,8 @@ mod tests { let parent_index = dir.to_string_lossy().split(['/', '\\']).filter(|p| !p.is_empty()).count() - 1; assert_eq!(h.request("ui.clickWidget", json!({"id": format!("crumb:{parent_index}")}), t)["ok"], true); h.settle(SETTLE); - assert_eq!(h.app.session.browse.as_ref().unwrap().path, dir.to_string_lossy().replace('\\', "/")); + // the same folder, whatever the platform's separators (issue #538) + assert_eq!(std::path::Path::new(&h.app.session.browse.as_ref().unwrap().path), dir.as_path()); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 1b6644e0..9c0d6a0f 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -717,10 +717,33 @@ fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, b: &lightcraft_engi header.response.rect } +/// The folders a breadcrumb shows for `path`: each name with the path that opens it, spelled as +/// in `path` (its separators, a leading `/` or a UNC `\\server\share`; issue #538). A bare drive +/// (`C:`) gets its separator back: on its own it means the current folder on that drive. +pub(crate) fn crumbs(path: &str) -> Vec<(&str, String)> { + let mut out = Vec::new(); + let mut start = 0; + for (i, c) in path.char_indices().chain(std::iter::once((path.len(), '/'))) { + if c != '/' && c != '\\' { + continue; + } + if let Some(part) = path.get(start..i).filter(|p| !p.is_empty()) { + let mut to = path.get(..i).unwrap_or(path).to_string(); + if out.is_empty() && part.ends_with(':') && to == part { + to.push(if path.contains('\\') { '\\' } else { '/' }); + } + out.push((part, to)); + } + start = i + c.len_utf8(); + } + out +} + fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { let t = Tokens::get(ui.ctx()); ui.spacing_mut().item_spacing.x = 4.0; - let parts: Vec<&str> = path.split(['/', '\\']).filter(|p| !p.is_empty()).collect(); + let crumbs = crumbs(path); + let parts: Vec<&str> = crumbs.iter().map(|(name, _)| *name).collect(); // With all sidebars open even four ellipses and their separators can exceed the row. // Prefer the current folder, showing more ancestors as space permits. let visible = ((ui.available_width() / 80.0) as usize).clamp(1, 4); @@ -741,9 +764,10 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { if r.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } - if r.clicked() { - let prefix = if path.starts_with('/') { format!("/{}", parts[..=i].join("/")) } else { parts[..=i].join("/") }; - let _ = app.run("library.browse", json!({"path": prefix})); + if r.clicked() + && let Some((_, to)) = crumbs.get(i) + { + let _ = app.run("library.browse", json!({"path": to})); } ui.label(egui::RichText::new("›").size(13.0).color(t.text_dim)); } diff --git a/crates/ui-egui/src/panels/grid_header_tests.rs b/crates/ui-egui/src/panels/grid_header_tests.rs index c55bffb5..46399162 100644 --- a/crates/ui-egui/src/panels/grid_header_tests.rs +++ b/crates/ui-egui/src/panels/grid_header_tests.rs @@ -2,6 +2,21 @@ use egui::{Rect, vec2}; use crate::{LightcraftApp, Services, headless::HeadlessView, i18n::Locale}; +/// Issue #538: a breadcrumb opens its folder spelled as the path is, keeping a UNC share's leading +/// `\\` and giving a bare drive its root back. +#[test] +fn breadcrumbs_open_their_folder_as_spelled() { + let to = |path: &str| super::crumbs(path).into_iter().map(|(name, to)| (name.to_string(), to)).collect::>(); + let pairs = |v: &[(&str, &str)]| v.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect::>(); + assert_eq!(to("/Users/me/Pictures"), pairs(&[("Users", "/Users"), ("me", "/Users/me"), ("Pictures", "/Users/me/Pictures")])); + assert_eq!(to(r"C:\Users\me"), pairs(&[("C:", r"C:\"), ("Users", r"C:\Users"), ("me", r"C:\Users\me")])); + assert_eq!(to("C:/Users"), pairs(&[("C:", "C:/"), ("Users", "C:/Users")])); + assert_eq!(to(r"\\server\share\photos"), pairs(&[("server", r"\\server"), ("share", r"\\server\share"), ("photos", r"\\server\share\photos")])); + assert_eq!(to("relative/dir/"), pairs(&[("relative", "relative"), ("dir", "relative/dir")])); + assert_eq!(to("/Fotos/día 1/写真"), pairs(&[("Fotos", "/Fotos"), ("día 1", "/Fotos/día 1"), ("写真", "/Fotos/día 1/写真")])); + assert!(to("").is_empty() && to("/").is_empty() && to(r"\\").is_empty()); +} + fn text_bounds(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(text) => out.push(text.visual_bounding_rect()),