cloud storage client
git clone https://git.lucas.co/cce-cloud.git
icons: cce's own artwork by desktop-file ID, and bigger icon rows
An entry's icon is first looked up as a cce-icons glyph named after its
desktop-file ID (icon_override: one stat in $XDG_DATA_HOME's hicolor
scalable/apps), in the launcher and in the switcher's app_id index. That
is the only way to replace an Icon= that is an absolute path (Houdini),
missing (Raindrop), or shared by several apps (Avahi's network-wired);
overrides named after Icon= already won through the theme search.
Icons are 26px in 36px rows (were 17 in 25) wherever the list has an icon
column; text-only lists keep 25px rows. item_h() replaces ITEM_H on every
index->y path, and a gutter change re-sizes the scroll bounds.
Also passes damage: None to Frame2D, the field cce-ui@3386087 added;
without it this crate did not build against its own pin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 12 ++++++-
src/main.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++++-------------
2 files changed, 103 insertions(+), 25 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 2783cf8..49f3999 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -122,6 +122,12 @@ bound with plain `get_keyboard`, and a held Backspace deleted one character.
`StartupWMClass`, or last reverse-DNS component, falling back to the app_id
itself, which is the name cce's own apps install their icons under. The rows
stream in over stdin, so `resolve_switcher_icons` runs on each ingest.
+ Both paths first ask `icon_override` for a cce-icons glyph named after the
+ entry's **desktop-file ID** (`$XDG_DATA_HOME/icons/hicolor/scalable/apps/<id>.svg`,
+ one stat), which is how cce draws its own icon for an entry whose `Icon=` is
+ an absolute path, missing, or a generic name several apps share — see
+ `cce-icons/hicolor/README.md`. Overrides named after the `Icon=` value need
+ nothing here: the user's data dir is the theme search's first base dir.
Icons are uploaded per popup on purpose (`cce_ui::icon::upload_themed` caches
the decode, not the image id) because `Drop` destroys this app's `VkRenderer`
between popups and an id cached across them would name freed GPU resources.
@@ -169,7 +175,11 @@ in each of the paint, scroll and hit-test paths: the strip shifts the whole list
down by its own height, and a path that missed the shift would put the rows, the
clip and the click out of step. The icon gutter is per-tab
(`recompute_icon_gutter`), so the System page's rows sit flush left while the
-Apps page keeps its column.
+Apps page keeps its column — and so is the row height: `item_h()` is
+`ICON_ITEM_H` (36, for a 26px icon) while a gutter exists and `ITEM_H` (25)
+otherwise. Every index→y path reads `item_h()`, and a gutter change re-runs
+`update_scroll`, because the icons usually land after `set_items` has already
+sized the scroll bounds for text rows.
The strip is painted by hand in the toolkit's recessed `ButtonStrip` idiom — one
well carved into the window plate, segments on its floor, the active one a
diff --git a/src/main.rs b/src/main.rs
index 22ba21e..46d0ea3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -334,6 +334,7 @@ fn parse_desktop_file(path: &std::path::Path) -> Option<AppInfo> {
if is_application && !no_display {
if let (Some(n), Some(e)) = (name, exec) {
+ let icon = path.file_stem().and_then(|s| s.to_str()).and_then(icon_override).or(icon);
return Some(AppInfo { name: n, exec: e, terminal, icon });
}
}
@@ -346,16 +347,7 @@ fn parse_desktop_file(path: &std::path::Path) -> Option<AppInfo> {
/// exports visible.
fn application_dirs() -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new();
- let data_home = std::env::var("XDG_DATA_HOME")
- .ok()
- .filter(|v| !v.is_empty())
- .map(std::path::PathBuf::from)
- .or_else(|| {
- std::env::var("HOME")
- .ok()
- .map(|h| std::path::PathBuf::from(h).join(".local/share"))
- });
- if let Some(data_home) = data_home {
+ if let Some(data_home) = data_home() {
dirs.push(data_home.join("applications"));
}
let data_dirs = std::env::var("XDG_DATA_DIRS")
@@ -370,6 +362,41 @@ fn application_dirs() -> Vec<std::path::PathBuf> {
dirs
}
+/// `$XDG_DATA_HOME`, defaulting per the base-directory spec.
+fn data_home() -> Option<std::path::PathBuf> {
+ std::env::var("XDG_DATA_HOME")
+ .ok()
+ .filter(|v| !v.is_empty())
+ .map(std::path::PathBuf::from)
+ .or_else(|| {
+ std::env::var("HOME")
+ .ok()
+ .map(|h| std::path::PathBuf::from(h).join(".local/share"))
+ })
+}
+
+/// cce's own icon for the desktop entry `id` (its file stem), when cce-icons
+/// ships one: `hicolor/scalable/apps/<id>.svg` under `$XDG_DATA_HOME/icons`,
+/// where `ccebuild install` puts that tree. Returned as an absolute path, so
+/// it bypasses the theme search entirely.
+///
+/// This is what lets cce draw its own artwork for apps it does not own. An
+/// override named after the entry's `Icon=` value already wins without help
+/// (the user's data dir is searched first), but that cannot reach an entry
+/// whose `Icon=` is an absolute path (Houdini's PNG), is missing (Raindrop),
+/// or is a generic name several apps share (`network-wired` for all three
+/// Avahi browsers) — those are overridden by desktop-file ID instead. One stat
+/// per entry: the theme walk `cce_ui::icon::lookup` does would cost ~200 per
+/// miss, for every app, on every launcher open.
+fn icon_override(id: &str) -> Option<String> {
+ icon_override_in(&data_home()?.join("icons/hicolor/scalable/apps"), id)
+}
+
+fn icon_override_in(dir: &std::path::Path, id: &str) -> Option<String> {
+ let path = dir.join(format!("{id}.svg"));
+ path.is_file().then(|| path.to_string_lossy().into_owned())
+}
+
fn scan_apps() -> Vec<AppInfo> {
let mut apps = Vec::new();
let dirs = application_dirs();
@@ -430,6 +457,7 @@ fn split_switcher_row(item: &str) -> (&str, &str) {
/// the last reverse-DNS component is indexed too, so `org.gnome.Nautilus`
/// matches a window that reports plain `nautilus`. Unlike [`scan_apps`] this
/// keeps NoDisplay entries: a helper window is still a window with an icon.
+/// A cce icon override for the entry ([`icon_override`]) replaces its `Icon=`.
fn desktop_icon_index(dirs: &[std::path::PathBuf]) -> std::collections::HashMap<String, String> {
let mut index = std::collections::HashMap::new();
for dir in dirs {
@@ -456,7 +484,8 @@ fn desktop_icon_index(dirs: &[std::path::PathBuf]) -> std::collections::HashMap<
}
}
}
- let Some(icon) = icon.filter(|i| !i.is_empty()) else { continue };
+ // A cce override for the entry wins, as in [`parse_desktop_file`].
+ let Some(icon) = icon_override(stem).or(icon.filter(|i| !i.is_empty())) else { continue };
let stem = stem.to_lowercase();
let short = stem.rsplit('.').next().map(str::to_string);
// First claim wins, in XDG precedence — the same shadowing
@@ -884,10 +913,10 @@ pub struct TabPage {
query: String,
}
-/// Icon edge length inside a 25px row. The gap between it and the label is
-/// the toolkit's control text inset, the same standoff the label keeps from
-/// the selection chip's edge.
-const ICON_PX: f32 = 17.0;
+/// Icon edge length inside an [`ICON_ITEM_H`] row. The gap between it and the
+/// label is the toolkit's control text inset, the same standoff the label
+/// keeps from the selection chip's edge.
+const ICON_PX: f32 = 26.0;
/// The list chrome's metrics. The spacing around them — the inset from the
/// popup edge, the gap under the tab strip and under the search well, the
@@ -900,6 +929,10 @@ const ICON_PX: f32 = 17.0;
/// go out of step.
const SEARCH_H: f32 = 35.0;
const ITEM_H: f32 = 25.0;
+/// Row height once the list carries icons (Apps mode, the window switcher):
+/// the icon plus a 5px standoff above and below. Text-only lists — dmenu,
+/// Path, the System tab — keep the tighter [`ITEM_H`].
+const ICON_ITEM_H: f32 = 36.0;
/// Height of the tab strip's segmented run; the strip claims this plus one
/// root-plate gap off the top (see [`FuzzelWidget::tab_strip_h`]).
const TAB_RUN_H: f32 = 22.0;
@@ -1099,7 +1132,20 @@ impl FuzzelWidget {
/// (see [`Self::set_item_icons`]).
fn recompute_icon_gutter(&mut self) {
let any = self.all_items.iter().any(|t| self.icons.contains_key(t));
- self.icon_gutter = if any { ICON_PX + cce_ui::layout::CONTROL_TEXT_INSET } else { 0.0 };
+ let gutter = if any { ICON_PX + cce_ui::layout::CONTROL_TEXT_INSET } else { 0.0 };
+ if gutter != self.icon_gutter {
+ self.icon_gutter = gutter;
+ // The row height follows the gutter (see `item_h`), so the
+ // content height the scroll bounds hold just changed.
+ self.update_scroll();
+ }
+ }
+
+ /// Height of one row: taller when the list has an icon column. Every
+ /// path that turns an index into a y — paint, hit-test, scroll bounds,
+ /// keyboard snap, the popup's own height — reads it here.
+ pub fn item_h(&self) -> f32 {
+ if self.icon_gutter > 0.0 { ICON_ITEM_H } else { ITEM_H }
}
/// Give rows an icon column. Apps mode sets its whole map here; the
@@ -1158,7 +1204,7 @@ impl FuzzelWidget {
}
pub fn update_scroll(&mut self) {
- let content_h = self.filtered_items.len() as f32 * ITEM_H;
+ let content_h = self.filtered_items.len() as f32 * self.item_h();
self.scroll_box.update_bounds_raw(content_h, self.list_y(), self.list_h());
self.refresh_hover();
}
@@ -1170,7 +1216,7 @@ impl FuzzelWidget {
/// and a row `get_draw_y` places in the viewport — partially visible
/// rows included, drawn cut by the clip, so an edge sliver counts.
fn row_at(&self, px: f32, py: f32) -> Option<usize> {
- let item_h = ITEM_H;
+ let item_h = self.item_h();
if !self.scroll_box.hit(px, py) || self.scroll_box.hit_scrollbar(px, py) {
return None;
}
@@ -1209,7 +1255,7 @@ impl FuzzelWidget {
}
pub fn snap_to_selected(&mut self) {
- let item_h = ITEM_H;
+ let item_h = self.item_h();
let viewport_h = self.list_h();
let content_h = self.filtered_items.len() as f32 * item_h;
@@ -1331,7 +1377,7 @@ impl cce_ui::widget::Paint for FuzzelWidget {
};
ctx.clip(viewport, |ctx| {
// Selected Item Highlight
- let item_h = ITEM_H;
+ let item_h = self.item_h();
if !self.filtered_items.is_empty() {
let virtual_selected_y = self.selected as f32 * item_h;
if let Some(draw_y) = self.scroll_box.get_draw_y(virtual_selected_y, item_h) {
@@ -1372,7 +1418,7 @@ impl cce_ui::widget::Paint for FuzzelWidget {
// only rows `get_draw_y` places in the viewport are emitted, so a
// 300-app list still costs one image quad per visible row.
if self.icon_gutter > 0.0 {
- let item_h = ITEM_H;
+ let item_h = self.item_h();
for (idx, item_text) in self.filtered_items.iter().enumerate() {
let Some((image, iw, ih)) = self.icons.get(item_text).copied() else { continue };
if let Some(draw_y) = self.scroll_box.get_draw_y(idx as f32 * item_h, item_h) {
@@ -2111,7 +2157,7 @@ impl State {
}
let num_items = self.fuzzel.filtered_items.len();
let item_count = if num_items == 0 { 1 } else { num_items };
- let needed_height = self.fuzzel.chrome_h() + (item_count as f32) * ITEM_H;
+ let needed_height = self.fuzzel.chrome_h() + (item_count as f32) * self.fuzzel.item_h();
let target_height = needed_height.min(self.max_height as f32);
// Calculate max text width
@@ -2357,6 +2403,8 @@ impl State {
images: &self.frame_images,
plate_features: &self.plate_features,
clear_color: [0.0; 4],
+ // Always a full frame: the popup is small and repaints whole.
+ damage: None,
});
false
}
@@ -4748,6 +4796,24 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
+ #[test]
+ fn icon_override_is_keyed_by_desktop_id() {
+ let dir = std::path::PathBuf::from("/tmp/cce-cloud-test-icon-override");
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("houdini.svg"), "<svg/>").unwrap();
+
+ // The ID's own file, as an absolute path - an entry whose Icon= is
+ // an absolute path or missing still gets cce's artwork.
+ assert_eq!(
+ icon_override_in(&dir, "houdini").as_deref(),
+ Some(dir.join("houdini.svg").to_str().unwrap())
+ );
+ // No override: the caller falls back to the entry's own Icon=.
+ assert_eq!(icon_override_in(&dir, "raindropio"), None);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn scan_apps_xdg_precedence() {
let base = std::path::PathBuf::from("/tmp/cce-cloud-test-xdg");
@@ -5024,7 +5090,7 @@ impl FuzzelWidget {
/// in) the viewport. Emitted under the paint walk's list clip, separately
/// from [`Self::own_labels`], which draws chrome outside it.
fn row_labels(&self) -> Vec<TextLabel> {
- let item_h = ITEM_H;
+ let item_h = self.item_h();
let mut labels = Vec::new();
for (idx, item_text) in self.filtered_items.iter().enumerate() {
let virtual_y = idx as f32 * item_h;
@@ -5043,7 +5109,9 @@ impl FuzzelWidget {
// Indented past the icon column whether or not THIS row
// resolved an icon — see `icon_gutter`.
x: self.text_x() + self.icon_gutter,
- y: draw_y + 4.0,
+ // 4px down in a text-only row; a taller icon row
+ // centres the same line box.
+ y: draw_y + (item_h - ITEM_H) / 2.0 + 4.0,
font_size: 13.0,
color,
});