lattice_listing/listing_mode.rs
1//! `directory-listing-mode` — the **minor** mode both listing majors
2//! share, owning entry presentation.
3//!
4//! Design: `docs/dev/architecture/directory-listing-mode.md`.
5//! Sequencing: `docs/dev/operations/slice-plans/directory-listing-mode.md`
6//! (this is DL.2).
7//!
8//! ## Why a minor rather than two contributions
9//!
10//! [`crate::oil::OilMode`] and [`crate::file_tree::FileTreeMode`] need
11//! identical *presentation* — a per-row icon and a per-row colour keyed
12//! on what the row points at — while differing in *behaviour* (oil is
13//! editable and diffs its rope on `:w`; the tree is read-only and
14//! expands directories). Shared behaviour across two majors is a minor
15//! mode, never the same contribution declared twice.
16//!
17//! CV.5 is the bill for not having had one: the identical off-by-scroll
18//! bug sat in both majors' paint paths, the report named only oil, and
19//! nothing announced that the tree was broken the same way. See
20//! `prefer-minor-modes-over-duplication`.
21//!
22//! ## What it owns, and what it does not
23//!
24//! Owns the theme-element vocabulary (§4 of the design), the display
25//! options a listing pane needs, and — from DL.3 — the per-row icons
26//! and spans.
27//!
28//! Owns **no keymap**. `<CR>` means "open" in the tree and nothing in
29//! oil, so entry navigation is major-owned. A mode owning no chords is
30//! fine; what it must not do is own half of something.
31
32use std::path::PathBuf;
33
34use lattice_mode::{
35 ActivationPolicy, BufferLocal, CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId,
36 ModeKind, OptionOverrideSet,
37};
38use lattice_theme::{ColorRef, ElementName, ElementOwner, StyleSpec, ThemeRegistryHandle};
39
40/// One row of a listing, in the shape entry presentation needs:
41/// where it points and whether it is a directory.
42///
43/// This is the type the minor and both majors share. It exists because
44/// activation alone needs no dependency between them — the
45/// [`ActivationPolicy`] names major ids — but the *entry data* does:
46/// the mode has to know, per row, what to draw an icon for. Both majors
47/// already modelled this, differently
48/// ([`crate::oil::OilEntry`] as `{ name, is_dir }`,
49/// [`crate::file_tree::FileTreeEntry`] as `{ path, depth, kind }`), and
50/// converging those two onto this one is DL.6.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ListingEntry {
53 /// Path the row refers to. Absolute where the major knows the
54 /// directory; otherwise a bare file name, which is enough for the
55 /// icon lookup (it inspects only the file name and extension).
56 pub path: PathBuf,
57 pub is_dir: bool,
58 /// Byte offset within the row where the icon is spliced.
59 ///
60 /// `0` for a flat listing (oil), whose rows are bare filenames.
61 /// The file tree indents and prefixes an expand marker, so its
62 /// icon anchors *after* those — at byte 0 the glyph would land to
63 /// the left of the indent and the tree's shape would collapse.
64 pub icon_byte: u32,
65 /// Byte length of the entry NAME, which begins at [`Self::icon_byte`]
66 /// (the icon is virtual text and occupies no source byte).
67 ///
68 /// Carried rather than re-derived, because neither the path nor the
69 /// icon anchor is enough to recover it. `path.file_name()` is wrong
70 /// for the tree's root row, whose text is the whole path; and
71 /// "`icon_byte` to end of line" needs the rope, which would put a
72 /// per-publish `as_string()` of the entire listing on the actor
73 /// thread to learn something both majors already computed when they
74 /// rendered the row.
75 pub name_byte_len: u32,
76}
77
78/// Per-row entry data for a listing buffer, written by whichever major
79/// owns the buffer and read by this mode's presentation.
80#[derive(Debug, Clone, Default)]
81pub struct ListingEntries(pub Vec<ListingEntry>);
82
83impl BufferLocal for ListingEntries {
84 const NAME: &'static str = "directory-listing-mode.entries";
85 const DOC: &'static str = "Per-row listing entries (path + is-dir) for an oil or file-tree \
86 buffer. Written by the owning major, read by directory-listing-mode to \
87 resolve each row's icon and theme element.";
88 const OWNER_MODE: &'static str = "directory-listing-mode";
89 fn describe(&self) -> String {
90 format!("{} entries", self.0.len())
91 }
92}
93
94/// Theme element every listing row falls back to.
95pub const ELEM_LISTING_FILE: &str = "listing.file";
96/// Directory rows.
97pub const ELEM_LISTING_DIR: &str = "listing.dir";
98/// Dotfiles.
99pub const ELEM_LISTING_HIDDEN: &str = "listing.hidden";
100
101/// The per-language elements, as `(element name, palette key)`.
102///
103/// Names are dotted and inherit through [`ElementName::parent`], so a
104/// theme retunes `listing.file` to move every language that does not
105/// override, or pins one language on its own.
106///
107/// Defaults are **palette keys, not literals**, and every key here is
108/// one [`lattice_theme::default_palette`] actually defines. The
109/// devicons table in `lattice_core::ui::icons` hardcodes RGB per
110/// extension (`"rs" => 0xDEA584`); mapping each onto the nearest role
111/// in the active palette is what makes these respond to a colourscheme
112/// at all, which is the entire point of rooting them here. A theme that
113/// wants a literal back sets it explicitly on the element.
114///
115/// An unknown palette key resolves to the inherited parent rather than
116/// failing loudly, so a typo here is invisible except as "every
117/// language looks the same" — which is what the family/one-language
118/// test below is really guarding.
119pub const LISTING_LANGUAGE_ELEMENTS: &[(&str, &str)] = &[
120 ("listing.file.rust", "orange"),
121 ("listing.file.c", "blue"),
122 ("listing.file.jvm", "red"),
123 ("listing.file.kotlin", "purple"),
124 ("listing.file.swift", "orange"),
125 ("listing.file.go", "cyan"),
126 ("listing.file.python", "yellow"),
127 ("listing.file.ruby", "red"),
128 ("listing.file.php", "purple"),
129 ("listing.file.lua", "blue"),
130 ("listing.file.haskell", "purple"),
131 ("listing.file.elixir", "purple"),
132 ("listing.file.erlang", "pink"),
133 ("listing.file.clojure", "green"),
134 ("listing.file.javascript", "yellow"),
135 ("listing.file.typescript", "blue"),
136 ("listing.file.html", "orange"),
137 ("listing.file.css", "blue"),
138 ("listing.file.sass", "pink"),
139 ("listing.file.web-component", "green"),
140 ("listing.file.config", "yellow"),
141 ("listing.file.json", "yellow"),
142 ("listing.file.yaml", "green"),
143 ("listing.file.shell", "green"),
144 ("listing.file.markup", "text"),
145 ("listing.file.sql", "overlay"),
146 ("listing.file.graphql", "pink"),
147 ("listing.file.infra", "purple"),
148 ("listing.file.lock", "overlay"),
149];
150
151/// Register every `listing.*` element under `owner`. Idempotent by
152/// name, so re-activation on a second listing buffer is free.
153///
154/// Separate from [`DirectoryListingMode::on_activate`] so tests (and
155/// the theme introspection surfaces) can register the vocabulary
156/// without standing up a mode context.
157pub fn register_listing_theme_elements(
158 reg: &dyn lattice_theme::ThemeRegistry,
159 owner: ElementOwner,
160) {
161 reg.register(
162 ElementName::from_static(ELEM_LISTING_FILE),
163 owner.clone(),
164 StyleSpec::new().fg(ColorRef::Palette("text".into())),
165 "Listing row: a file with no more specific language element.",
166 );
167 reg.register(
168 ElementName::from_static(ELEM_LISTING_DIR),
169 owner.clone(),
170 // Bold as well as blue. Directories are the one row kind whose
171 // colour also has to survive a listing where every OTHER row is
172 // coloured too (DL.8b paints names, not just icons), and blue
173 // alone against a wall of accents is weaker than it was against
174 // plain text. It is also what the painters DL.4/DL.5 deleted
175 // used (`file_tree_dir_style`: blue + bold) and what `Directory`
176 // resolves to in most colourschemes.
177 StyleSpec::new().fg(ColorRef::Palette("blue".into())).bold(),
178 "Listing row: a directory.",
179 );
180 reg.register(
181 ElementName::from_static(ELEM_LISTING_HIDDEN),
182 owner.clone(),
183 StyleSpec::new()
184 .inherit(ELEM_LISTING_FILE.to_string())
185 .dim(),
186 "Listing row: a dotfile.",
187 );
188 for (name, palette) in LISTING_LANGUAGE_ELEMENTS {
189 reg.register(
190 ElementName::from((*name).to_string()),
191 owner.clone(),
192 StyleSpec::new()
193 .inherit(ELEM_LISTING_FILE.to_string())
194 .fg(ColorRef::Palette((*palette).into())),
195 "Listing row: language-specific file colour.",
196 );
197 }
198}
199
200/// Which `listing.*` element a row resolves to.
201///
202/// DL.3b: this is the replacement for `ext_color`'s runtime lookup.
203/// `ext_color` answers "what RGB is a `.rs` file" and a theme cannot
204/// touch the answer; this answers "which registered element is it",
205/// and the theme owns what that element looks like.
206///
207/// Buckets are language *families*, not extensions — the vocabulary a
208/// theme is asked to retune should be the one a person thinks in.
209pub fn listing_element_for(path: &std::path::Path, is_dir: bool) -> &'static str {
210 if is_dir {
211 return ELEM_LISTING_DIR;
212 }
213 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
214 if name.starts_with('.') {
215 return ELEM_LISTING_HIDDEN;
216 }
217 let ext = path
218 .extension()
219 .and_then(|e| e.to_str())
220 .unwrap_or("")
221 .to_ascii_lowercase();
222 match name {
223 "Makefile" | "makefile" | "GNUmakefile" | "CMakeLists.txt" | "Dockerfile"
224 | "dockerfile" | "Containerfile" => return "listing.file.config",
225 "LICENSE" | "LICENCE" => return "listing.file.markup",
226 _ => {}
227 }
228 match ext.as_str() {
229 "rs" => "listing.file.rust",
230 "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" => "listing.file.c",
231 "cs" | "java" | "scala" => "listing.file.jvm",
232 "kt" | "kts" => "listing.file.kotlin",
233 "swift" => "listing.file.swift",
234 "go" => "listing.file.go",
235 "py" | "pyw" | "pyi" => "listing.file.python",
236 "rb" | "erb" => "listing.file.ruby",
237 "php" => "listing.file.php",
238 "lua" => "listing.file.lua",
239 "hs" | "lhs" => "listing.file.haskell",
240 "ex" | "exs" => "listing.file.elixir",
241 "erl" | "hrl" => "listing.file.erlang",
242 "clj" | "cljs" | "cljc" => "listing.file.clojure",
243 "js" | "mjs" | "cjs" | "coffee" => "listing.file.javascript",
244 "ts" | "tsx" | "jsx" => "listing.file.typescript",
245 "html" | "htm" => "listing.file.html",
246 "css" | "less" => "listing.file.css",
247 "scss" | "sass" => "listing.file.sass",
248 "vue" | "svelte" => "listing.file.web-component",
249 "toml" | "ini" | "cfg" | "conf" => "listing.file.config",
250 "json" | "jsonc" | "json5" => "listing.file.json",
251 "yaml" | "yml" => "listing.file.yaml",
252 "sh" | "bash" | "zsh" | "fish" | "ps1" | "psm1" | "vim" => "listing.file.shell",
253 "md" | "mdx" | "rst" | "txt" | "org" => "listing.file.markup",
254 "sql" => "listing.file.sql",
255 "graphql" | "gql" => "listing.file.graphql",
256 "tf" | "hcl" | "nix" => "listing.file.infra",
257 "lock" => "listing.file.lock",
258 _ => ELEM_LISTING_FILE,
259 }
260}
261
262/// Build one leading inlay per listing row: the glyph
263/// [`lattice_core::ui::icons::glyph_for_entry`] picks, painted with the
264/// element [`listing_element_for`] resolves.
265///
266/// A pure function of the entries plus the theme's interned ids, so it
267/// is testable without a buffer, an editor, or a frame — the producer
268/// side of DL.3b is just "call this and publish the result".
269///
270/// Each icon anchors at its entry's `icon_byte` — 0 for oil's flat
271/// rows, after the indent and expand marker for the tree. Keeping the
272/// glyph out of the rope is what lets oil's text stay bare filenames
273/// (design §6): it renders, the buffer never contains it, and `:w`
274/// still diffs clean.
275pub fn listing_inlays(
276 entries: &[ListingEntry],
277 reg: &dyn lattice_theme::ThemeRegistry,
278 nerd_fonts: bool,
279) -> Vec<lattice_mode::InlayRow> {
280 entries
281 .iter()
282 .enumerate()
283 .map(|(line, e)| {
284 // DL.6: `glyph_for_entry`, not `entry_visual` — the colour
285 // comes from the theme element below, so asking for the
286 // devicons RGB and discarding it was the last thing keeping
287 // `ext_color` alive.
288 let glyph = lattice_core::ui::icons::glyph_for_entry(&e.path, e.is_dir, nerd_fonts);
289 let name = ElementName::from_static(listing_element_for(&e.path, e.is_dir));
290 // The element is registered by `on_activate`; if a caller
291 // somehow beat that, fall back to the plain-hint style
292 // rather than dropping the icon.
293 let style = reg
294 .id(&name)
295 .map(lattice_cells::Style::Element)
296 .unwrap_or(lattice_cells::Style::InlayHint);
297 lattice_mode::InlayRow {
298 line: line as u32,
299 byte: e.icon_byte,
300 text: glyph.to_string(),
301 style,
302 }
303 })
304 .collect()
305}
306
307/// Build one span per listing row covering the entry NAME, painted with
308/// the same element [`listing_element_for`] gives that row's icon.
309///
310/// DL.8b. The icon alone carried the colour until now, which left every
311/// name — including directories — painting as plain text. That was not
312/// the design (§2 has the mode owning "the per-row spans **and** icons");
313/// only the icon half had shipped, and the bespoke painters DL.4/DL.5
314/// deleted did style directory and dotfile names before that.
315///
316/// ## Why the whole name, and what that spends
317///
318/// Editor file trees are unanimous the other way: nvim-tree, neo-tree,
319/// oil.nvim, Zed and VS Code all paint the ICON by file type and reserve
320/// the NAME's colour for *state* — git status, hidden, symlink,
321/// executable, opened. Colouring the name by type is the terminal-lister
322/// convention (`ls --color` / eza / lf) and Emacs `diredfl`.
323///
324/// The louder one is the deliberate choice here, with one constraint:
325/// **the language colour is the lowest-precedence layer on a row.** Each
326/// line's span goes LAST in its vector, and `style_at_byte` is
327/// first-match-wins, so any state span a later slice prepends — git
328/// status, symlink, executable — wins over the language colour without
329/// re-plumbing this. Spending the name channel on type is reversible;
330/// spending it in a way that BLOCKS state would not be.
331///
332/// Rows whose name is empty produce an empty span list rather than a
333/// zero-width span, so a degenerate entry cannot leave a stray run.
334pub fn listing_name_spans(
335 entries: &[ListingEntry],
336 reg: &dyn lattice_theme::ThemeRegistry,
337) -> Vec<Vec<lattice_cells::StyledSpan>> {
338 entries
339 .iter()
340 .map(|e| {
341 if e.name_byte_len == 0 {
342 return Vec::new();
343 }
344 let name = ElementName::from_static(listing_element_for(&e.path, e.is_dir));
345 // No registered element ⇒ no span, rather than a span in some
346 // stand-in style. The row then paints as ordinary text, which
347 // is what it did before this function existed; inventing a
348 // colour here would be worse than the absence.
349 let Some(id) = reg.id(&name) else {
350 return Vec::new();
351 };
352 vec![lattice_cells::StyledSpan {
353 start: e.icon_byte as usize,
354 end: (e.icon_byte + e.name_byte_len) as usize,
355 style: lattice_cells::Style::Element(id),
356 }]
357 })
358 .collect()
359}
360
361/// The shared presentation minor. See the module docs.
362pub struct DirectoryListingMode;
363
364impl DirectoryListingMode {
365 pub fn mode_id() -> ModeId {
366 ModeId::new("directory-listing-mode")
367 }
368}
369
370impl Mode for DirectoryListingMode {
371 type Guard = ();
372
373 fn id(&self) -> ModeId {
374 Self::mode_id()
375 }
376
377 fn kind(&self) -> ModeKind {
378 ModeKind::Minor
379 }
380
381 /// Both listing majors, and nothing else.
382 fn activation_policy(&self) -> ActivationPolicy {
383 ActivationPolicy::Majors(vec![
384 crate::oil::modes::OilMode::mode_id(),
385 crate::file_tree::modes::FileTreeMode::mode_id(),
386 ])
387 }
388
389 /// What a listing pane needs from the *generic* render path.
390 ///
391 /// These are **options, not kind checks** — which is what lets the
392 /// shared compose path render a listing without a
393 /// `match buffer_kind`. A regular Document with these settings
394 /// renders identically, and that is the test the convergence has to
395 /// pass (DL.4/DL.5).
396 ///
397 /// `ReadOnly` is deliberately absent: it is per-major (the tree is
398 /// read-only, oil is not), so it stays with the majors.
399 ///
400 /// `Number` is absent for exactly the same reason (2026-08-16). A
401 /// file tree is a navigation surface and hides line numbers, like
402 /// every tree UI; oil is an ordinary editable buffer where `3dd`
403 /// over three files genuinely wants a count, and it is the only
404 /// editable buffer in the editor that would otherwise have no
405 /// gutter. Sharing one answer forced the wrong one on one of them,
406 /// so the override moved to `file-tree-mode`.
407 fn options(&self) -> OptionOverrideSet {
408 lattice_config::overrides! {
409 lattice_config::Wrap = false,
410 lattice_config::SignColumnOption = lattice_config::SignColumn::No,
411 // DL.4: a listing's selected row must be visible. The
412 // bespoke painters reverse-videoed it; on the shared path
413 // that is the cursorline, which is an option any buffer
414 // can carry — so the listing asks for it rather than the
415 // renderer special-casing the kind.
416 lattice_config::CursorLine = true,
417 }
418 }
419
420 fn required_capabilities(&self) -> CapabilitySet {
421 CapabilitySet::empty()
422 }
423
424 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, ()> {
425 Box::pin(async move {
426 // The mode is the single source of its element vocabulary
427 // (`feedback_mode_owns_its_surface`). Idempotent by name, so
428 // activating on a second listing buffer re-registers
429 // harmlessly and returns the same interned ids.
430 //
431 // A missing service is a test harness without a theme, not
432 // an error: skip, and rows resolve to their default style.
433 // Same tolerance `multibuffer-mode` and `compilation-mode`
434 // apply at the same seam.
435 if let Some(theme) = ctx
436 .service::<ThemeRegistryHandle>()
437 .map(|outer| (*outer).clone())
438 {
439 register_listing_theme_elements(
440 theme.as_ref(),
441 ElementOwner::Mode(Self::mode_id().as_str().to_string().into()),
442 );
443 }
444 Ok(())
445 })
446 }
447}
448
449/// Register the shared listing minor. Called from the host's mode boot
450/// beside `oil::register_oil_modes` / `file_tree::register_file_tree_modes`.
451pub fn register_listing_modes(registry: &mut lattice_mode::ModeRegistry) {
452 registry
453 .register(DirectoryListingMode)
454 .expect("directory-listing-mode register");
455}
456
457#[cfg(test)]
458mod tests {
459 #![allow(clippy::unwrap_used)]
460 use super::*;
461 use lattice_theme::{InMemoryThemeRegistry, ThemeRegistry};
462
463 fn owner() -> ElementOwner {
464 ElementOwner::Mode(DirectoryListingMode::mode_id().as_str().to_string().into())
465 }
466
467 #[test]
468 fn activates_on_both_listing_majors_and_nothing_else() {
469 let policy = DirectoryListingMode.activation_policy();
470 let ActivationPolicy::Majors(majors) = policy else {
471 panic!("directory-listing-mode must be major-scoped");
472 };
473 assert!(majors.contains(&crate::oil::modes::OilMode::mode_id()));
474 assert!(majors.contains(&crate::file_tree::modes::FileTreeMode::mode_id()));
475 assert_eq!(
476 majors.len(),
477 2,
478 "the policy is the whole list of majors this mode presents for — \
479 adding one silently gives it listing presentation"
480 );
481 }
482
483 #[test]
484 fn is_a_minor_and_claims_no_keymap() {
485 assert_eq!(DirectoryListingMode.kind(), ModeKind::Minor);
486 let km = DirectoryListingMode.keymap();
487 assert!(
488 km.bindings.is_empty() && km.entries.is_empty(),
489 "entry navigation is major-owned: `<CR>` opens in the tree and \
490 means nothing in oil"
491 );
492 }
493
494 #[test]
495 fn registers_every_listing_element_idempotently() {
496 let reg = InMemoryThemeRegistry::with_defaults();
497 register_listing_theme_elements(®, owner());
498 let first: Vec<_> = LISTING_LANGUAGE_ELEMENTS
499 .iter()
500 .map(|(n, _)| reg.id(&ElementName::from((*n).to_string())).unwrap())
501 .collect();
502 for name in [ELEM_LISTING_FILE, ELEM_LISTING_DIR, ELEM_LISTING_HIDDEN] {
503 assert!(
504 reg.id(&ElementName::from_static(name)).is_some(),
505 "{name} must be registered"
506 );
507 }
508
509 // Re-activation on a second listing buffer must not mint new ids.
510 register_listing_theme_elements(®, owner());
511 let second: Vec<_> = LISTING_LANGUAGE_ELEMENTS
512 .iter()
513 .map(|(n, _)| reg.id(&ElementName::from((*n).to_string())).unwrap())
514 .collect();
515 assert_eq!(first, second, "registration must be idempotent by name");
516 }
517
518 /// The reason this is rooted in the theme system at all: a theme can
519 /// retune the parent and move every language that does not override,
520 /// or pin a single language.
521 #[test]
522 fn a_theme_can_retune_the_family_or_one_language() {
523 let reg = InMemoryThemeRegistry::with_defaults();
524 register_listing_theme_elements(®, owner());
525
526 let rust = reg
527 .id(&ElementName::from_static("listing.file.rust"))
528 .unwrap();
529 let sql = reg
530 .id(&ElementName::from_static("listing.file.sql"))
531 .unwrap();
532
533 // Baseline: the two languages differ.
534 let resolved = reg.resolved();
535 assert_ne!(
536 resolved.get(rust).fg,
537 resolved.get(sql).fg,
538 "precondition: per-language colours differ out of the box"
539 );
540
541 // A theme pins one language.
542 reg.register(
543 ElementName::from_static("listing.file.rust"),
544 ElementOwner::Core,
545 StyleSpec::new().fg(ColorRef::Literal(lattice_theme::Color::Rgb(1, 2, 3))),
546 "theme override",
547 );
548 // Idempotent-by-name means an existing element keeps its
549 // owner-supplied default; a THEME overrides through the theme
550 // layer, not by re-registering. Assert the id is stable so the
551 // override path has something to address.
552 assert_eq!(
553 reg.id(&ElementName::from_static("listing.file.rust"))
554 .unwrap(),
555 rust,
556 "an element's id is stable across re-registration — that is what \
557 a theme override addresses"
558 );
559 }
560
561 /// DL.3b: every row gets one leading icon, coloured by the element
562 /// its path resolves to — the replacement for `ext_color`'s
563 /// untouchable RGB table.
564 #[test]
565 fn listing_inlays_anchor_one_icon_per_row_with_its_own_element() {
566 let reg = InMemoryThemeRegistry::with_defaults();
567 register_listing_theme_elements(®, owner());
568
569 let entries = vec![
570 ListingEntry {
571 path: PathBuf::from("src"),
572 is_dir: true,
573 icon_byte: 0,
574 name_byte_len: 3,
575 },
576 ListingEntry {
577 path: PathBuf::from("main.rs"),
578 is_dir: false,
579 icon_byte: 0,
580 name_byte_len: 7,
581 },
582 ListingEntry {
583 path: PathBuf::from("notes.md"),
584 is_dir: false,
585 icon_byte: 4,
586 name_byte_len: 8,
587 },
588 ];
589 let rows = listing_inlays(&entries, ®, true);
590 assert_eq!(rows.len(), entries.len(), "one icon per row");
591
592 for (i, r) in rows.iter().enumerate() {
593 assert_eq!(r.line, i as u32, "row {i} anchors to its own line");
594 assert_eq!(
595 r.byte, entries[i].icon_byte,
596 "the icon anchors where its entry says — 0 for a flat row, \
597 after the indent + marker for a tree row"
598 );
599 assert!(!r.text.is_empty(), "row {i} must carry a glyph");
600 assert!(
601 matches!(r.style, lattice_cells::Style::Element(_)),
602 "row {i} must name a registered element, not fall back to \
603 the plain hint colour"
604 );
605 }
606
607 // The directory and the two file kinds resolve to three
608 // different elements — if they collapsed, the palette would be
609 // theme-rooted in name only.
610 let ids: Vec<_> = rows.iter().map(|r| r.style).collect();
611 assert_ne!(ids[0], ids[1], "a directory differs from a Rust file");
612 assert_ne!(ids[1], ids[2], "a Rust file differs from markup");
613 }
614
615 /// DL.8b: a row's NAME carries the same element as its icon, spanning
616 /// exactly the name — from the icon anchor, for the name's length.
617 ///
618 /// Before this the icon alone was coloured, so every filename —
619 /// directories included — painted as plain text. The tree's indent
620 /// and expand marker must stay OUT of the span: colouring them too
621 /// would tint the structural glyphs a directory's own colour and make
622 /// the tree's shape read as content.
623 #[test]
624 fn listing_name_spans_cover_the_name_with_the_rows_own_element() {
625 let reg = InMemoryThemeRegistry::with_defaults();
626 register_listing_theme_elements(®, owner());
627
628 let entries = vec![
629 // A tree row: indent + marker occupy the first 4 bytes.
630 ListingEntry {
631 path: PathBuf::from("/p/src"),
632 is_dir: true,
633 icon_byte: 4,
634 name_byte_len: 3,
635 },
636 // An oil row: the whole line is the name.
637 ListingEntry {
638 path: PathBuf::from("/p/main.rs"),
639 is_dir: false,
640 icon_byte: 0,
641 name_byte_len: 7,
642 },
643 ListingEntry {
644 path: PathBuf::from("/p/app.py"),
645 is_dir: false,
646 icon_byte: 0,
647 name_byte_len: 6,
648 },
649 ];
650 let spans = listing_name_spans(&entries, ®);
651 assert_eq!(spans.len(), entries.len(), "one entry per row, in order");
652
653 assert_eq!(
654 (spans[0][0].start, spans[0][0].end),
655 (4, 7),
656 "the span starts at the icon anchor and covers only the name — \
657 a tree's indent and expand marker are structure, not content, \
658 and must not take the directory's colour"
659 );
660 assert_eq!(
661 (spans[1][0].start, spans[1][0].end),
662 (0, 7),
663 "an oil row's name is the whole line"
664 );
665
666 // The icon and the name of one row must name the SAME element —
667 // that is the property that keeps a glyph from disagreeing with
668 // the text beside it.
669 let icons = listing_inlays(&entries, ®, true);
670 for (i, (icon, name)) in icons.iter().zip(spans.iter()).enumerate() {
671 assert_eq!(
672 icon.style, name[0].style,
673 "row {i}: the icon and the name must resolve one element"
674 );
675 }
676 // …and different rows must differ, or the colouring is uniform in
677 // all but name.
678 assert_ne!(
679 spans[0][0].style, spans[1][0].style,
680 "a dir differs from Rust"
681 );
682 assert_ne!(
683 spans[1][0].style, spans[2][0].style,
684 "Rust differs from Python"
685 );
686 }
687
688 /// The name span is the LOWEST-precedence layer on a row.
689 ///
690 /// `merge_extra_spans` prepends the published list to the syntax
691 /// spans and `style_at_byte` is first-match-wins, so position within
692 /// the line's vector IS precedence. Keeping the language colour last
693 /// is what leaves the name's colour available to a later state layer
694 /// — git status, symlink, executable — which is the channel every
695 /// other editor's file tree spends the name on. One span per row is
696 /// how that stays true: a producer prepends, it never has to reorder.
697 #[test]
698 fn the_name_span_is_last_so_a_state_layer_can_win() {
699 let reg = InMemoryThemeRegistry::with_defaults();
700 register_listing_theme_elements(®, owner());
701 let spans = listing_name_spans(
702 &[ListingEntry {
703 path: PathBuf::from("/p/main.rs"),
704 is_dir: false,
705 icon_byte: 0,
706 name_byte_len: 7,
707 }],
708 ®,
709 );
710 assert_eq!(
711 spans[0].len(),
712 1,
713 "exactly one span per row — a later state layer prepends to \
714 win, and cannot if it has to interleave with several"
715 );
716 }
717
718 /// A row with no name, and a registry that never saw the vocabulary,
719 /// both produce NO span rather than a zero-width one or a stand-in
720 /// colour. A row painting as ordinary text is what it did before
721 /// DL.8b; inventing a colour would be worse than the absence.
722 #[test]
723 fn a_nameless_row_or_an_unregistered_theme_yields_no_span() {
724 let reg = InMemoryThemeRegistry::with_defaults();
725 register_listing_theme_elements(®, owner());
726 let nameless = listing_name_spans(
727 &[ListingEntry {
728 path: PathBuf::from("/p"),
729 is_dir: false,
730 icon_byte: 0,
731 name_byte_len: 0,
732 }],
733 ®,
734 );
735 assert!(nameless[0].is_empty(), "no name, no span");
736
737 let bare = InMemoryThemeRegistry::with_defaults();
738 let unregistered = listing_name_spans(
739 &[ListingEntry {
740 path: PathBuf::from("/p/main.rs"),
741 is_dir: false,
742 icon_byte: 0,
743 name_byte_len: 7,
744 }],
745 &bare,
746 );
747 assert!(
748 unregistered[0].is_empty(),
749 "an unregistered vocabulary paints plain text, never a stand-in"
750 );
751 }
752
753 /// A directory is bold as well as blue — the weight the painters
754 /// DL.4/DL.5 deleted carried (`file_tree_dir_style`), restored now
755 /// that every OTHER row is coloured too and blue alone has more to
756 /// compete with than it did against plain text.
757 #[test]
758 fn a_directory_resolves_bold_blue() {
759 let reg = InMemoryThemeRegistry::with_defaults();
760 register_listing_theme_elements(®, owner());
761 let id = reg
762 .id(&ElementName::from_static(ELEM_LISTING_DIR))
763 .expect("registered");
764 let style = reg.resolved().get(id);
765 assert!(style.modifiers.bold, "a directory row must read as bold");
766 assert_eq!(
767 style.fg,
768 lattice_theme::default_palette().get(&"blue".into()),
769 "and blue — from the palette, so a colourscheme moves it"
770 );
771 }
772
773 #[test]
774 fn listing_element_buckets_by_family_and_flags_dotfiles() {
775 let e = |p: &str, d: bool| listing_element_for(&PathBuf::from(p), d);
776 assert_eq!(e("src", true), ELEM_LISTING_DIR);
777 assert_eq!(e(".gitignore", false), ELEM_LISTING_HIDDEN);
778 assert_eq!(
779 e(".config", true),
780 ELEM_LISTING_DIR,
781 "a hidden DIRECTORY is still a directory — the dir check wins"
782 );
783 assert_eq!(e("main.rs", false), "listing.file.rust");
784 // Family bucketing: two extensions, one element.
785 assert_eq!(e("a.ts", false), e("b.tsx", false));
786 assert_eq!(e("Cargo.toml", false), "listing.file.config");
787 assert_eq!(e("Makefile", false), "listing.file.config");
788 // Unknown extensions fall back to the family root rather than
789 // vanishing.
790 assert_eq!(e("mystery.qqq", false), ELEM_LISTING_FILE);
791 assert_eq!(e("noext", false), ELEM_LISTING_FILE);
792 }
793
794 #[test]
795 fn contributes_listing_display_options_but_not_read_only() {
796 use std::any::TypeId;
797 let opts = DirectoryListingMode.options();
798 let ids: Vec<TypeId> = opts.iter().map(|o| o.option_type_id).collect();
799
800 for (want, why) in [
801 (
802 TypeId::of::<lattice_config::Wrap>(),
803 "listing rows do not wrap",
804 ),
805 (
806 TypeId::of::<lattice_config::SignColumnOption>(),
807 "listings reserve no sign column",
808 ),
809 (
810 TypeId::of::<lattice_config::CursorLine>(),
811 "the selected row must be visible",
812 ),
813 ] {
814 assert!(ids.contains(&want), "{why}");
815 }
816 assert!(
817 !ids.contains(&TypeId::of::<lattice_config::ReadOnly>()),
818 "read-only is per-major — the tree is, oil is not — so it must \
819 stay with the majors"
820 );
821 assert!(
822 !ids.contains(&TypeId::of::<lattice_config::Number>()),
823 "line numbers are per-major for the same reason read-only is: a \
824 tree hides them, oil is an editable buffer and keeps them"
825 );
826 }
827
828 /// The split this replaced a shared override with. Asserting both
829 /// sides in one test is the point — the bug was that ONE answer was
830 /// forced on two majors that want different ones, so a test that
831 /// only checked the tree would have passed before the fix too.
832 #[test]
833 fn line_numbers_are_hidden_in_the_tree_and_kept_in_oil() {
834 use std::any::TypeId;
835 let tree: Vec<TypeId> = crate::file_tree::FileTreeMode
836 .options()
837 .iter()
838 .map(|o| o.option_type_id)
839 .collect();
840 assert!(
841 tree.contains(&TypeId::of::<lattice_config::Number>()),
842 "the file tree is a navigation surface — it hides line numbers"
843 );
844
845 let oil: Vec<TypeId> = crate::oil::OilMode
846 .options()
847 .iter()
848 .map(|o| o.option_type_id)
849 .collect();
850 assert!(
851 !oil.contains(&TypeId::of::<lattice_config::Number>()),
852 "oil is an ordinary editable buffer — it must not override \
853 `number`, so it inherits the global default and `3dd` over three \
854 files has a count to read"
855 );
856 }
857}