Skip to main content

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(&reg, 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(&reg, 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(&reg, 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(&reg, 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, &reg, 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(&reg, 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, &reg);
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, &reg, 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(&reg, 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            &reg,
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(&reg, 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            &reg,
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(&reg, 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}