git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commit376dd5c85978ae9d585cdd1e95c4b3e2a72c2078
parentf16d49dd91
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 21:59
fix: every symbol cce-files draws is a cce-icons glyph

The browse rows, the preview's details header and the graph nodes wore
emoji (folder, page, picture, note, film, package, scroll) drawn as text in
whatever face the font fell back to. They are cce-icons glyphs now, by
name: entry_icon maps a row's kind to folder / file / file-image /
file-audio / file-video / file-archive / file-code, the preview header
draws folder or file, and each graph node wears its glyph on the left of
its body, mirroring the geometry toggle on the right. Node names lose the
emoji prefix; the kind rides in NetworkState::node_glyphs.

PageContent grows an icons list (and RenderTarget::icon), flattened to
WidgetFx::Icon and painted through PaintCtx::icon in the part's order,
cut to its clip rather than squashed like the fills. A directory listed in
a text preview is name/ instead of an emoji prefix. Page::icon, which
nothing called, is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md            |  1 +
 src/main.rs          | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/pages/browse.rs  | 42 +++++++++++++++++++++++++++++----------
 src/pages/mod.rs     | 33 ++++++++++++++++++++++---------
 src/pages/network.rs | 55 +++++++++++++++++++++++++++++++++++++--------------
 src/preview_pane.rs  |  5 +++--
 src/row_list.rs      | 13 ++++++++----
 src/services/fs.rs   |  5 +++--
 8 files changed, 166 insertions(+), 44 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 3c62992..b482827 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -67,4 +67,5 @@ Two things to know when touching it:
 - **Chooser modes**: launched with `--select`, `--select-dir`, or `--save`, the app becomes a file picker for other CCE apps — it shows a bottom action bar, prints the chosen path to stdout, and `std::process::exit(0)` on selection (or exit code 1 on cancel). This is why `SelectOpen`/`SelectCancel` call `process::exit` directly.
 - **Persistence**: the last-visited directory is saved to `~/.config/cce/cce-files/cce-files-last-dir.txt` and restored on launch.
 - **Double-click**: opening is temporal — `last_click_time` / `last_clicked_idx` in `update()` detect a double-click within 500ms rather than relying on a windowing double-click event.
+- **Symbols are cce-icons glyphs, never characters**: a row's kind (`entry_icon` → `folder`, `file-image`, `file-code`, …), the preview header and the graph nodes draw a glyph by NAME through `PageContent::icon` / `icon_bounded`, which `rebuild_layout` flattens to `WidgetFx::Icon` and paints with `PaintCtx::icon` in the part's order (cut to its clip, not squashed). No emoji or symbol characters in labels, node names or text bodies — a directory listed in a text preview is `name/`.
 - **Fonts**: `cce_ui::create_font_system()` loads bundled fonts from `cce_ui::fonts_dir()` — `$CCE_FONTS_DIR`, else `$HOME/Dropbox/Fonts`. It is resolved, not hardcoded, and the override is what a shadow session needs: a shadow HOME cannot see the real `~/Dropbox/Fonts`, so without `CCE_FONTS_DIR` its screenshots render in a fallback sans. Set `CCE_LOAD_SYSTEM_FONTS` to also load system fonts.
diff --git a/src/main.rs b/src/main.rs
index 648880d..540e4e8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -112,6 +112,15 @@ fn prompt_rects(win_w: f32, win_h: f32) -> PromptRects {
     }
 }
 
+/// The overlap of two rects (zero-sized where they do not meet).
+fn intersect_rect(a: cce_ui::scene::layout::Rect, b: cce_ui::scene::layout::Rect) -> cce_ui::scene::layout::Rect {
+    let x0 = a.x.max(b.x);
+    let y0 = a.y.max(b.y);
+    let x1 = (a.x + a.width).min(b.x + b.width);
+    let y1 = (a.y + a.height).min(b.y + b.height);
+    cce_ui::scene::layout::Rect { x: x0, y: y0, width: (x1 - x0).max(0.0), height: (y1 - y0).max(0.0) }
+}
+
 /// Clip a vertical span `[y, y+h)` to the viewport `[top, bottom)`.
 /// Returns the clipped `(y, h)`, or `None` if fully outside.
 fn clip_to_viewport(y: f32, h: f32, top: f32, bottom: f32) -> Option<(f32, f32)> {
@@ -225,7 +234,7 @@ const SELECT_BAR_H: f32 = 48.0;
 
 /// How a flat quad renders under the SDF-lit plate system. `Flat` is the plain
 /// fill; the relief variants carry the roll/carve depth in px.
-#[derive(Clone, Copy, PartialEq)]
+#[derive(Clone, PartialEq)]
 enum WidgetFx {
     Flat,
     /// A lit Bevel plate: the quad's own fill plus a rolled, lit lip (raised
@@ -254,6 +263,9 @@ enum WidgetFx {
     /// A GPU-textured quad; the id comes from `cce_ui::vk::upload_rgba`
     /// (the preview pane's image). `color` is unused.
     Image { id: u32, alpha: f32 },
+    /// A cce-icons glyph tinted with the widget's `color` (`PaintCtx::icon`),
+    /// cut to `clip` when it has one.
+    Icon { name: String, clip: Option<cce_ui::scene::layout::Rect> },
     /// A line engraved from (ax, ay) to (bx, by) into the widget's rect, which
     /// is the HOST surface here rather than the mark's own bounds — the shading
     /// fades out across the host's rolled edge. The breadcrumb's slanted seams;
@@ -1039,6 +1051,37 @@ impl FilesystemApp {
                     fx: WidgetFx::Image { id: *id, alpha: *alpha },
                 });
             }
+            for (name, ix, iy, iw, ih, color, bounds) in &pc_part.icons {
+                // Cut, never squashed: a glyph is a picture, so a row scrolled
+                // half under the list's edge loses its half rather than being
+                // pressed flat the way the fills are clipped.
+                let mut clip = bounds.map(|[l, t, r, b]| cce_ui::scene::layout::Rect { x: l, y: t, width: r - l, height: b - t });
+                if is_page_content {
+                    let view = cce_ui::scene::layout::Rect { x: *ix, y: content_y, width: *iw, height: content_h };
+                    clip = Some(match clip {
+                        Some(c) => intersect_rect(c, view),
+                        None => view,
+                    });
+                }
+                if let Some(c) = clip {
+                    if c.width <= 0.0 || c.height <= 0.0
+                        || c.x >= ix + iw || c.x + c.width <= *ix
+                        || c.y >= iy + ih || c.y + c.height <= *iy
+                    {
+                        continue;
+                    }
+                }
+                widgets.push(AppWidget {
+                    x: *ix,
+                    y: *iy,
+                    w: *iw,
+                    h: *ih,
+                    color: *color,
+                    radius: 0.0,
+                    corners: (true, true, true, true),
+                    fx: WidgetFx::Icon { name: name.clone(), clip },
+                });
+            }
             for (rx, ry, rw, rh, rr, rd, kind) in &pc_part.reliefs {
                 let (mut wy, mut wh) = (*ry, *rh);
                 if is_page_content {
@@ -1773,7 +1816,7 @@ impl Application for FilesystemApp {
         for w in &self.widgets {
             let rect = Rect { x: w.x, y: w.y, width: w.w, height: w.h };
             let radii = (w.radius, w.radius, w.radius, w.radius);
-            match w.fx {
+            match w.fx.clone() {
                 WidgetFx::Bevel(depth) => pc.bevel(rect, radii, &cce_ui::scene::Material::from_fill(w.color), depth),
                 WidgetFx::Plate(depth) => pc.plate(rect, radii, &cce_ui::scene::Material::from_fill(w.color), depth),
                 WidgetFx::Boss(depth) => pc.boss(rect, radii, depth),
@@ -1787,6 +1830,15 @@ impl Application for FilesystemApp {
                     pc.inset_plate_tinted(rect, radii, cce_ui::scene::Material::face(w.color).as_ref(), depth, cce_ui::widget::ControlPlate::focus_tint())
                 }
                 WidgetFx::Image { id, alpha } => pc.image(id, rect, alpha),
+                WidgetFx::Icon { name, clip } => {
+                    if let Some(c) = clip {
+                        pc.push_clip(c);
+                    }
+                    pc.icon(&name, rect, w.color);
+                    if clip.is_some() {
+                        pc.pop_clip();
+                    }
+                }
                 WidgetFx::Groove { ax, ay, bx, by, width, depth } => {
                     pc.groove((ax, ay), (bx, by), width, depth, rect)
                 }
diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index d9a045f..5be0815 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -178,18 +178,22 @@ pub fn read_directory(path: &Path) -> Vec<DirEntry> {
 
 use crate::util::{format_size, format_permissions};
 
+/// The cce-icons glyph a row wears for its entry, by kind and extension.
 fn entry_icon(is_dir: bool, name: &str) -> &'static str {
     if is_dir {
-        return "📁";
+        return "folder";
     }
-    match name.rsplit('.').next() {
-        Some("rs") | Some("toml") | Some("json") | Some("yaml") | Some("yml") => "📄",
-        Some("png") | Some("jpg") | Some("jpeg") | Some("svg") | Some("gif") => "🖼",
-        Some("mp3") | Some("wav") | Some("flac") | Some("ogg") => "🎵",
-        Some("mp4") | Some("mkv") | Some("avi") | Some("webm") => "🎬",
-        Some("zip") | Some("tar") | Some("gz") | Some("bz2") | Some("xz") => "📦",
-        Some("py") | Some("sh") | Some("bash") => "📜",
-        _ => "📄",
+    let ext = match name.rsplit_once('.') {
+        Some((_, ext)) => ext.to_ascii_lowercase(),
+        None => return "file",
+    };
+    match ext.as_str() {
+        "png" | "jpg" | "jpeg" | "svg" | "gif" | "webp" | "bmp" | "ico" | "tif" | "tiff" => "file-image",
+        "mp3" | "wav" | "flac" | "ogg" | "opus" | "m4a" | "aac" => "file-audio",
+        "mp4" | "mkv" | "avi" | "webm" | "mov" => "file-video",
+        "zip" | "tar" | "gz" | "bz2" | "xz" | "zst" | "7z" | "rar" | "tgz" => "file-archive",
+        "rs" | "py" | "sh" | "bash" | "zsh" | "c" | "h" | "cpp" | "hpp" | "js" | "ts" | "go" | "lua" | "wgsl" => "file-code",
+        _ => "file",
     }
 }
 
@@ -335,7 +339,7 @@ pub fn view(state: &mut BrowseState, view_dropdown: &mut cce_ui::widget::Adapted
 
         crate::row_list::Row {
             cells,
-            icon: Some(entry_icon(entry.is_dir, &entry.name).to_string()),
+            icon: Some(entry_icon(entry.is_dir, &entry.name)),
             selected: state.selected == Some(idx),
         }
     }).collect();
@@ -567,6 +571,24 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::serv
 mod tests {
     use super::*;
 
+    /// Every row wears a cce-icons glyph by NAME — never a character — and
+    /// the kind decides which.
+    #[test]
+    fn entry_icon_names_a_glyph_by_kind() {
+        assert_eq!(entry_icon(true, "photos.png"), "folder");
+        assert_eq!(entry_icon(false, "Shot.PNG"), "file-image");
+        assert_eq!(entry_icon(false, "song.flac"), "file-audio");
+        assert_eq!(entry_icon(false, "clip.webm"), "file-video");
+        assert_eq!(entry_icon(false, "src.tar.gz"), "file-archive");
+        assert_eq!(entry_icon(false, "main.rs"), "file-code");
+        assert_eq!(entry_icon(false, "Cargo.toml"), "file");
+        assert_eq!(entry_icon(false, "README"), "file");
+        for (dir, name) in [(true, "a"), (false, "a.png"), (false, "a.mp3"), (false, "a.mp4"), (false, "a.zip"), (false, "a.py"), (false, "a")] {
+            let glyph = entry_icon(dir, name);
+            assert!(glyph.chars().all(|c| c.is_ascii_lowercase() || c == '-'), "{glyph:?} is not a glyph name");
+        }
+    }
+
     fn state_with_count(count: usize) -> BrowseState {
         BrowseState {
             entries: (0..count)
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 66236f5..4c97d97 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -26,14 +26,6 @@ impl Page {
             Page::Space => "Space",
         }
     }
-
-    pub fn icon(self) -> &'static str {
-        match self {
-            Page::Browse => "📁",
-            Page::Network => "🌐",
-            Page::Space => "▦",
-        }
-    }
 }
 
 /// Mirror the breadcrumb's relief into a flat-path [`PageContent`]: the
@@ -173,6 +165,12 @@ pub struct PageContent {
     /// alpha). Drawn after the part's rects, so a fill emitted earlier is the floor
     /// beneath the image and overlay parts still cover it.
     pub images: Vec<(u32, f32, f32, f32, f32, f32)>,
+    /// cce-icons glyphs — (name, x, y, w, h, colour, clip `[l, t, r, b]`).
+    /// The colour is a text colour (raw sRGB, alpha = opacity), so a glyph and
+    /// the label beside it match; drawn through `PaintCtx::icon` in the part's
+    /// order, after its images. The clip cuts a glyph scrolled half under an
+    /// edge rather than squashing it, as text bounds cut a label.
+    pub icons: Vec<(String, f32, f32, f32, f32, [f32; 4], Option<[f32; 4]>)>,
     /// Lit plates — (color, x, y, w, h, radius, depth). Unlike [`reliefs`], a plate
     /// owns its FILL as well as its edge: one primitive carrying a rounded face and
     /// the rolled, lit perimeter, shaded in a single lighting evaluation. That is
@@ -193,6 +191,7 @@ impl PageContent {
             reliefs: Vec::new(),
             grooves: Vec::new(),
             images: Vec::new(),
+            icons: Vec::new(),
             plates: Vec::new(),
         }
     }
@@ -205,13 +204,14 @@ impl PageContent {
     /// exactly that reason. The destructuring below turns a new field into a
     /// compile error instead of a missing mark on screen.
     pub fn absorb(&mut self, other: PageContent) {
-        let PageContent { rects, texts, buttons, reliefs, grooves, images, plates } = other;
+        let PageContent { rects, texts, buttons, reliefs, grooves, images, icons, plates } = other;
         self.rects.extend(rects);
         self.texts.extend(texts);
         self.buttons.extend(buttons);
         self.reliefs.extend(reliefs);
         self.grooves.extend(grooves);
         self.images.extend(images);
+        self.icons.extend(icons);
         self.plates.extend(plates);
     }
 
@@ -220,6 +220,17 @@ impl PageContent {
         self.images.push((id, x, y, w, h, alpha));
     }
 
+    /// A cce-icons glyph (`folder`, `file-image`, …) at `(x, y, w, h)`, tinted
+    /// `color` as a label is — the one way this crate draws a symbol.
+    pub fn icon(&mut self, name: &str, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) {
+        self.icons.push((name.to_string(), x, y, w, h, color, None));
+    }
+
+    /// [`PageContent::icon`] cut to `bounds` `[l, t, r, b]`.
+    pub fn icon_bounded(&mut self, name: &str, x: f32, y: f32, w: f32, h: f32, color: [f32; 4], bounds: Option<[f32; 4]>) {
+        self.icons.push((name.to_string(), x, y, w, h, color, bounds));
+    }
+
     pub fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
         self.rects.push((color, x, y, w, h, 0.0, (true, true, true, true)));
     }
@@ -386,6 +397,10 @@ impl PageContent {
 }
 
 impl RenderTarget for PageContent {
+    fn icon(&mut self, name: &str, rect: cce_ui::scene::layout::Rect, color: [f32; 4]) {
+        PageContent::icon(self, name, rect.x, rect.y, rect.width, rect.height, color);
+    }
+
     fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
         self.rects.push((color, x, y, w, h, 0.0, (true, true, true, true)));
     }
diff --git a/src/pages/network.rs b/src/pages/network.rs
index bd71175..1d1a78b 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -7,6 +7,10 @@ pub struct NetworkState {
     pub graph: Adapted<Graph>,
     pub breadcrumb: Adapted<Breadcrumb>,
     pub last_dir: PathBuf,
+    /// The cce-icons glyph each graph node wears, by node index (`folder` /
+    /// `file`) — drawn over the node body by `view`, since a `GraphNode`
+    /// carries a name and no glyph.
+    pub node_glyphs: Vec<&'static str>,
 }
 
 impl Default for NetworkState {
@@ -27,6 +31,7 @@ impl Default for NetworkState {
             graph,
             breadcrumb,
             last_dir: PathBuf::new(),
+            node_glyphs: Vec::new(),
         }
     }
 }
@@ -34,6 +39,7 @@ impl Default for NetworkState {
 impl NetworkState {
     pub fn populate_graph(&mut self, current_dir: &Path, entries: &[DirEntry]) {
         let mut nodes = Vec::new();
+        let mut glyphs = Vec::new();
 
         // 1. Parent directory (if any)
         let parent_node_name = if let Some(parent) = current_dir.parent() {
@@ -41,7 +47,8 @@ impl NetworkState {
                 .file_name()
                 .map(|s| s.to_string_lossy().to_string())
                 .unwrap_or_else(|| "/".to_string());
-            let name = format!("📁 .. ({})", parent_name);
+            let name = format!(".. ({})", parent_name);
+            glyphs.push("folder");
             nodes.push(GraphNode {
                 id: String::new(),
                 name: name.clone(),
@@ -58,11 +65,11 @@ impl NetworkState {
         };
 
         // 2. Current directory node
-        let current_name = current_dir
+        let current_node_name = current_dir
             .file_name()
             .map(|s| s.to_string_lossy().to_string())
             .unwrap_or_else(|| "/".to_string());
-        let current_node_name = format!("📁 {}", current_name);
+        glyphs.push("folder");
 
         let current_params = if let Some(ref p_name) = parent_node_name {
             vec![("input".to_string(), p_name.clone(), "string".to_string())]
@@ -83,8 +90,8 @@ impl NetworkState {
 
         // 3. Children nodes
         for (idx, entry) in entries.iter().enumerate() {
-            let icon = if entry.is_dir { "📁" } else { "📄" };
-            let node_name = format!("{} {}", icon, entry.name);
+            let node_name = entry.name.clone();
+            glyphs.push(if entry.is_dir { "folder" } else { "file" });
 
             // Spreading items in 7 columns starting at row 2
             let col = (idx % 7) as f32;
@@ -103,6 +110,7 @@ impl NetworkState {
         }
 
         self.graph.set_nodes(&nodes);
+        self.node_glyphs = glyphs;
         self.last_dir = current_dir.to_path_buf();
     }
 }
@@ -169,6 +177,19 @@ pub fn view(state: &mut NetworkState, browse: &BrowseState, view_dropdown: &mut
     // Render the Graph widget into PageContent, shifted down by 28.0 to leave room for the breadcrumb
     cce_ui::layout::render_widget(&mut pc, &mut state.graph, cx, cy + 28.0, cw, ch - 28.0, ctx);
 
+    // Each node's glyph on the left of its body, mirroring the geometry
+    // toggle the graph draws on the right (`Graph::toggle_rect`): the same
+    // size, the same inset, scaled with the zoom. Cut to the graph's rect, as
+    // its labels are.
+    let canvas = [cx, cy + 28.0, cx + cw, cy + ch];
+    let glyph_color = [0xcc as f32 / 255.0, 0xcc as f32 / 255.0, 0xd4 as f32 / 255.0, 1.0];
+    for (idx, glyph) in state.node_glyphs.iter().enumerate() {
+        let Some((nx, ny, nw, nh)) = state.graph.node_rect(idx) else { continue };
+        let scale_f = nw / 80.0;
+        let side = (18.0 * scale_f).clamp(6.0, 50.0);
+        pc.icon_bounded(glyph, nx + 6.0 * scale_f, ny + (nh - side) / 2.0, side, side, glyph_color, Some(canvas));
+    }
+
     pc
 }
 
@@ -208,25 +229,28 @@ mod tests {
         assert_eq!(nodes.len(), 4);
 
         // Check node names
-        assert_eq!(nodes[0].name, "📁 .. (user)");
-        assert_eq!(nodes[1].name, "📁 project");
-        assert_eq!(nodes[2].name, "📄 file1.txt");
-        assert_eq!(nodes[3].name, "📁 subdir");
+        assert_eq!(nodes[0].name, ".. (user)");
+        assert_eq!(nodes[1].name, "project");
+        assert_eq!(nodes[2].name, "file1.txt");
+        assert_eq!(nodes[3].name, "subdir");
+
+        // The kind rides as a glyph, not as a character in the name.
+        assert_eq!(state.node_glyphs, vec!["folder", "folder", "file", "folder"]);
 
         // Check connection parameters
         // Current directory points to parent
         assert_eq!(nodes[1].parameters.len(), 1);
         assert_eq!(nodes[1].parameters[0].0, "input");
-        assert_eq!(nodes[1].parameters[0].1, "📁 .. (user)");
+        assert_eq!(nodes[1].parameters[0].1, ".. (user)");
 
         // Children point to current directory
         assert_eq!(nodes[2].parameters.len(), 1);
         assert_eq!(nodes[2].parameters[0].0, "input");
-        assert_eq!(nodes[2].parameters[0].1, "📁 project");
+        assert_eq!(nodes[2].parameters[0].1, "project");
 
         assert_eq!(nodes[3].parameters.len(), 1);
         assert_eq!(nodes[3].parameters[0].0, "input");
-        assert_eq!(nodes[3].parameters[0].1, "📁 project");
+        assert_eq!(nodes[3].parameters[0].1, "project");
     }
 
     #[test]
@@ -250,8 +274,9 @@ mod tests {
 
         // No parent, so 1 current + 1 child = 2 nodes
         assert_eq!(nodes.len(), 2);
-        assert_eq!(nodes[0].name, "📁 /");
-        assert_eq!(nodes[1].name, "📁 bin");
+        assert_eq!(nodes[0].name, "/");
+        assert_eq!(nodes[1].name, "bin");
+        assert_eq!(state.node_glyphs, vec!["folder", "folder"]);
 
         // Current has no parent parameter
         assert!(nodes[0].parameters.is_empty());
@@ -259,6 +284,6 @@ mod tests {
         // Child points to current
         assert_eq!(nodes[1].parameters.len(), 1);
         assert_eq!(nodes[1].parameters[0].0, "input");
-        assert_eq!(nodes[1].parameters[0].1, "📁 /");
+        assert_eq!(nodes[1].parameters[0].1, "/");
     }
 }
diff --git a/src/preview_pane.rs b/src/preview_pane.rs
index ff52b4f..78fb957 100644
--- a/src/preview_pane.rs
+++ b/src/preview_pane.rs
@@ -379,7 +379,7 @@ impl PreviewPane {
         // is exactly root_plate_gap — the same separator width as everywhere
         // else on the plate (it was a hardcoded 12+7=19px before).
         let details_top = cy + half_h + cce_ui::layout::root_plate_gap();
-        let icon = if self.is_dir { "📁" } else { "📄" };
+        let icon = if self.is_dir { "folder" } else { "file" };
 
         let details = [
             ("Path", &self.path_display),
@@ -414,7 +414,8 @@ impl PreviewPane {
         // TODO(style): the header's 6px drop, the 42/112 label columns and
         // the 20px row pitch are the details form's own rhythm.
         let header_y = details_content_start_y + 6.0;
-        pc.text(icon, cx + inset, header_y, 20.0, text_fg);
+        // A 20px glyph centred on the 16px name beside it.
+        pc.icon(icon, cx + inset, header_y + 4.0 + (16.0 * 1.2 - 20.0) * 0.5, 20.0, 20.0, text_fg);
 
         // Pixel-measured budgets against the section frame's inner right edge
         // (None-font text renders sans-serif — measure with the same family).
diff --git a/src/row_list.rs b/src/row_list.rs
index 3afc70f..b0c2cfa 100644
--- a/src/row_list.rs
+++ b/src/row_list.rs
@@ -40,7 +40,9 @@ pub struct ListColumn {
 #[derive(Debug, Clone)]
 pub struct Row {
     pub cells: Vec<String>,
-    pub icon: Option<String>,
+    /// The cce-icons glyph shown ahead of the first cell (`folder`,
+    /// `file-image`, …), drawn in the first cell's colour.
+    pub icon: Option<&'static str>,
     pub selected: bool,
 }
 
@@ -450,9 +452,12 @@ impl RowList {
             let y_secondary = cce_ui::layout::center_text_y(draw_y, self.item_height, secondary_size);
 
             let mut start_text_offset = 8.0;
-            if let Some(ref icon) = row.icon {
+            if let Some(icon) = row.icon {
                 if !col_bounds.is_empty() {
-                    push_text(pc, icon, x + col_bounds[0].0 + 12.0, y_primary, primary_size, row_fg, &font, clip);
+                    // A glyph the size of the name's text, centred on the row.
+                    let side = primary_size;
+                    let gy = draw_y + (self.item_height - side) * 0.5;
+                    pc.icon_bounded(icon, x + col_bounds[0].0 + 12.0, gy, side, side, row_fg, clip);
                     start_text_offset = 32.0;
                 }
             }
@@ -515,7 +520,7 @@ mod tests {
             ListColumn { name: "Size".into(), width: ColumnWidth::RightOffset(120.0), justification: Justification::Left },
         ];
         l.rows = (0..n)
-            .map(|i| Row { cells: vec![format!("f{i}"), "1 KiB".into()], icon: Some("D".into()), selected: false })
+            .map(|i| Row { cells: vec![format!("f{i}"), "1 KiB".into()], icon: Some("file"), selected: false })
             .collect();
         l.update_bounds_from_rows();
         l
diff --git a/src/services/fs.rs b/src/services/fs.rs
index 9f56d69..4c57dd3 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -449,8 +449,9 @@ fn load_preview_data_internal(path: &Path) -> PreviewData {
                 for entry in entries.flatten().take(100) {
                     let name = entry.file_name().to_string_lossy().to_string();
                     let is_sub_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
-                    let icon = if is_sub_dir { "📁" } else { "📄" };
-                    names.push(format!("{} {}", icon, name));
+                    // A text body: a directory wears the plain-text marker,
+                    // a trailing slash, rather than a pictogram.
+                    names.push(if is_sub_dir { format!("{name}/") } else { name });
                 }
                 if names.is_empty() {
                     content_preview = Some("[Empty directory]".to_string())