diff --git a/crates/ferese-theme/src/gallery.rs b/crates/ferese-theme/src/gallery.rs index 0283cfc..ab1e411 100644 --- a/crates/ferese-theme/src/gallery.rs +++ b/crates/ferese-theme/src/gallery.rs @@ -11,6 +11,7 @@ pub struct TileOptions { pub variant: Option, pub active: Option, pub selected: bool, + pub tab_stop: bool, pub palette: crate::Palette, pub font: cosmic::font::Font, pub id: Id, @@ -26,6 +27,7 @@ pub fn tile( variant, active, selected, + tab_stop, palette, font, id, @@ -61,7 +63,7 @@ pub fn tile( .padding(8) .class(tile_style(palette, selected)) .on_press(on_select); - radio(tile, id, selected, navigate) + radio_with_tab_stop(tile, id, selected, tab_stop, navigate) } fn tile_style(palette: crate::Palette, selected: bool) -> cosmic::theme::Button { @@ -247,6 +249,7 @@ pub struct Radio<'a, M> { content: Element<'a, M>, id: Id, selected: bool, + tab_stop: bool, group: bool, label: String, navigate: Option>, @@ -257,11 +260,22 @@ pub fn radio<'a, M: Clone + 'a>( id: Id, selected: bool, navigate: impl Fn(&keyboard::key::Named) -> Option + 'a, +) -> Element<'a, M> { + radio_with_tab_stop(content, id, selected, selected, navigate) +} + +fn radio_with_tab_stop<'a, M: Clone + 'a>( + content: impl Into>, + id: Id, + selected: bool, + tab_stop: bool, + navigate: impl Fn(&keyboard::key::Named) -> Option + 'a, ) -> Element<'a, M> { Element::new(Radio { content: content.into(), id, selected, + tab_stop, group: false, label: String::new(), navigate: Some(Box::new(navigate)), @@ -273,6 +287,7 @@ pub fn group<'a, M: Clone + 'a>(content: impl Into>, id: Id, labe content: content.into(), id, selected: false, + tab_stop: false, group: true, label: label.into(), navigate: None, @@ -305,7 +320,7 @@ impl Widget for Radio<'_, M> { self.content .as_widget_mut() .operate(&mut tree.children[0], layout, renderer, operation); - } else if self.selected { + } else if self.tab_stop { operation.focusable(Some(&self.id), layout.bounds(), tree.state.downcast_mut::()); } else { tree.state.downcast_mut::().0 = false; @@ -339,10 +354,10 @@ impl Widget for Radio<'_, M> { } } if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))) { - tree.state.downcast_mut::().0 = self.selected && cursor.is_over(layout.bounds()); + tree.state.downcast_mut::().0 = self.tab_stop && cursor.is_over(layout.bounds()); } } - if !self.group && self.selected && tree.state.downcast_ref::().0 { + if !self.group && self.tab_stop && tree.state.downcast_ref::().0 { if let Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Character(key), .. @@ -392,7 +407,7 @@ impl Widget for Radio<'_, M> { self.content .as_widget() .draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport); - if !self.group && self.selected && tree.state.downcast_ref::().0 { + if !self.group && self.tab_stop && tree.state.downcast_ref::().0 { use renderer::Renderer as _; renderer.fill_quad( renderer::Quad { diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 8b6e5cb..b3e968e 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -150,6 +150,7 @@ struct App { font: cosmic::font::Font, native_palette: visuals::Palette, family_ids: std::sync::Arc>, + family_sections: theme_controls::FamilySections, gallery_focus: Option<(String, Option)>, resolved: ferese_ipc::theme::Snapshot, undo_revision: u64, @@ -172,6 +173,11 @@ fn initial_visible_rows() -> std::collections::HashSet<(&'static str, usize)> { .into_iter() .flat_map(|list| (0..3).map(move |index| (list, index))), ) + .chain( + ["theme-custom", "theme-custom-light", "theme-custom-dark"] + .into_iter() + .map(|list| (list, 0)), + ) .collect() } @@ -217,6 +223,7 @@ impl cosmic::Application for App { let resolved = ferese_theme_client::service::current(); let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family)); let native_palette = visuals::Palette::from_resolved(&resolved.presented); + let family_sections = theme_controls::FamilySections::new(&resolved.families, ¤t); let mut app = Self { connections: connections::State { tab: connection_tab.unwrap_or_default(), @@ -252,6 +259,7 @@ impl cosmic::Application for App { font, native_palette, gallery_focus: None, + family_sections, family_ids: std::sync::Arc::new(resolved.families.iter().map(|family| family.id.clone()).collect()), resolved, undo_revision: 0, @@ -318,6 +326,7 @@ impl cosmic::Application for App { if self.resolved.families != snapshot.families { self.family_ids = std::sync::Arc::new(snapshot.families.iter().map(|family| family.id.clone()).collect()); + self.family_sections = theme_controls::FamilySections::new(&snapshot.families, &self.draft); } self.resolved = *snapshot; if font_changed { @@ -394,6 +403,8 @@ impl cosmic::Application for App { } else { self.current = snapshot.clone(); self.draft = snapshot; + self.family_sections = + theme_controls::FamilySections::new(&self.resolved.families, &self.draft); self.sync_notes(); self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); self.undo = None; @@ -486,12 +497,7 @@ impl cosmic::Application for App { self.hidden_binding_rows.remove(&index); } if let Some((id, appearance)) = &self.gallery_focus - && navigation::gallery_list(*appearance) == list - && self - .family_ids - .iter() - .position(|family| family == id) - .is_some_and(|position| position / 3 == index) + && self.gallery_position(id, *appearance) == Some((list, index)) { let (id, appearance) = self.gallery_focus.take().unwrap(); return cosmic::iced::advanced::widget::operate( @@ -579,6 +585,8 @@ impl cosmic::Application for App { } } + self.family_sections = + theme_controls::FamilySections::new(&self.resolved.families, &self.draft); self.sync_notes(); self.status = if live { "Saved · desktop updated" @@ -609,6 +617,7 @@ impl cosmic::Application for App { Ok(snapshot) => { self.current = snapshot.clone(); self.draft = snapshot; + self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft); self.note_editors.clear(); self.sync_notes(); self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); @@ -629,15 +638,14 @@ impl cosmic::Application for App { } } Message::Family(id, appearance) => { - let reveal = if let Some(index) = self.family_ids.iter().position(|family| family == &id) { - let list = navigation::gallery_list(appearance); - if !self.visible_rows.insert((list, index / 3)) { + let reveal = if let Some((list, row)) = self.gallery_position(&id, appearance) { + if !self.visible_rows.insert((list, row)) { self.gallery_focus = None; } else { // Focus after the sensor observes the newly mounted row. self.gallery_focus = Some((id.clone(), appearance)); } - cosmic::iced::advanced::widget::operate(navigation::RevealRow::new(list, index / 3)) + cosmic::iced::advanced::widget::operate(navigation::RevealRow::new(list, row)) .map(cosmic::Action::App) } else { Task::none() @@ -1100,6 +1108,44 @@ mod tests { assert!(app.gallery_focus.is_none()); } + #[test] + fn custom_theme_focus_waits_for_its_own_grid_in_linked_and_split_modes() { + for appearance in [ + None, + Some(ferese_config::theme::Appearance::Light), + Some(ferese_config::theme::Appearance::Dark), + ] { + let mut app = app(); + let registrations = (0..7) + .map(|index| format!("personal-{index} {{ file \"unused.kdl\"; }}\n")) + .collect::(); + app.draft = Snapshot::parse(format!("theme {{ custom-themes {{ {registrations} }} }}")).unwrap(); + let mut snapshot = app.resolved.clone(); + for index in 0..7 { + let mut family = snapshot.families[0].clone(); + family.id = format!("personal-{index}"); + snapshot.families.push(family); + } + let _ = app.update(Message::ThemeChanged(Box::new(snapshot))); + let id = "personal-6".to_owned(); + let list = navigation::gallery_list(appearance, true); + assert_eq!(app.gallery_position(&id, appearance), Some((list, 2))); + app.visible_rows.remove(&(list, 2)); + let _ = app.update(Message::Family(id.clone(), appearance)); + assert_eq!(app.gallery_focus, Some((id.clone(), appearance))); + let _ = app.update(Message::RowVisibility( + navigation::gallery_list(appearance, false), + 2, + true, + )); + assert!(app.gallery_focus.is_some(), "built-in row must not claim custom focus"); + let task = app.update(Message::RowVisibility(list, 2, true)); + assert!(task.units() > 0); + assert!(app.gallery_focus.is_none()); + assert_eq!(visuals::family_selection(&app.draft, appearance), id); + } + } + #[test] fn animated_palette_does_not_rebuild_native_theme_or_family_navigation() { let mut app = app(); diff --git a/shell/ferese-settings/src/navigation.rs b/shell/ferese-settings/src/navigation.rs index cc0dcdc..91b4d6d 100644 --- a/shell/ferese-settings/src/navigation.rs +++ b/shell/ferese-settings/src/navigation.rs @@ -2,11 +2,14 @@ use cosmic::iced::advanced::widget::Id; use cosmic::iced::advanced::widget::operation::{Operation, Scrollable}; use cosmic::iced::{Rectangle, Vector}; -pub(super) fn gallery_list(appearance: Option) -> &'static str { - match appearance { - Some(ferese_config::theme::Appearance::Light) => "theme-light", - Some(ferese_config::theme::Appearance::Dark) => "theme-dark", - None => "theme", +pub(super) fn gallery_list(appearance: Option, custom: bool) -> &'static str { + match (appearance, custom) { + (Some(ferese_config::theme::Appearance::Light), false) => "theme-light", + (Some(ferese_config::theme::Appearance::Dark), false) => "theme-dark", + (None, false) => "theme", + (Some(ferese_config::theme::Appearance::Light), true) => "theme-custom-light", + (Some(ferese_config::theme::Appearance::Dark), true) => "theme-custom-dark", + (None, true) => "theme-custom", } } diff --git a/shell/ferese-settings/src/theme_controls.rs b/shell/ferese-settings/src/theme_controls.rs index ece97af..4fa26cf 100644 --- a/shell/ferese-settings/src/theme_controls.rs +++ b/shell/ferese-settings/src/theme_controls.rs @@ -3,6 +3,39 @@ use super::{ schema, set, text_input, visuals, widget, }; +#[derive(Clone, Default)] +pub(super) struct FamilySections { + built_in: std::sync::Arc>, + custom: std::sync::Arc>, +} + +impl FamilySections { + pub(super) fn new(families: &[ferese_config::families::Family], snapshot: &crate::store::Snapshot) -> Self { + let (custom, built_in) = (0..families.len()).partition(|&index| { + snapshot + .item(&format!("theme.custom_themes.{}", families[index].id)) + .is_some() + }); + Self { + built_in: std::sync::Arc::new(built_in), + custom: std::sync::Arc::new(custom), + } + } + + fn indices(&self, custom: bool) -> &std::sync::Arc> { + if custom { &self.custom } else { &self.built_in } + } + + pub(super) fn position(&self, index: usize) -> Option<(bool, usize)> { + [false, true].into_iter().find_map(|custom| { + self.indices(custom) + .iter() + .position(|&family| family == index) + .map(|position| (custom, position)) + }) + } +} + impl App { pub(super) fn theme_file_picker(&self) -> Element<'static, Message> { let palette = visuals::Palette::from_resolved(&self.resolved.presented); @@ -158,7 +191,6 @@ impl App { pub(super) fn theme_gallery(&self, appearance: Option) -> Element<'_, Message> { use ferese_config::theme::Appearance::{Dark, Light}; - let choices = &self.resolved.families; let mut heading = row([]).align_y(Alignment::Center).spacing(6).push( self.label( match appearance { @@ -170,30 +202,57 @@ impl App { ) .width(Length::Fill), ); + if appearance != Some(Dark) && self.undo.is_some() { + heading = heading.push(self.settings_icon_button( + "Undo", + "M4 10h8 M4 10V3 M4 10c3-7 16-6 16 3a7 7 0 0 1-7 7", + (!self.saving).then_some(Message::Undo), + )); + } + let mut custom_heading = row([]) + .align_y(Alignment::Center) + .spacing(6) + .push(self.label("Custom themes", 14.).width(Length::Fill)); if appearance != Some(Dark) { - if self.undo.is_some() { - heading = heading.push(self.settings_icon_button( - "Undo", - "M4 10h8 M4 10V3 M4 10c3-7 16-6 16 3a7 7 0 0 1-7 7", - (!self.saving).then_some(Message::Undo), - )); - } - heading = heading.push(self.settings_button( + custom_heading = custom_heading.push(self.settings_button( "Import…", "M3 7V5h6l2 2h10v13H3z M12 10v7 M9 14l3 3 3-3", Some(Message::ImportTheme), false, )); } - let mut gallery = column([]).spacing(6).push(heading); - let list = crate::navigation::gallery_list(appearance); - for chunk in 0..choices.len().div_ceil(3) { + + let mut sections = column([]).spacing(12).push( + column([]) + .spacing(6) + .push(heading) + .push(self.theme_gallery_grid(appearance, false)), + ); + if appearance != Some(Dark) || !self.family_sections.custom.is_empty() { + sections = sections.push( + column([]) + .spacing(6) + .push(custom_heading) + .push(self.theme_gallery_grid(appearance, true)), + ); + } + + sections.into() + } + + fn theme_gallery_grid( + &self, + appearance: Option, + custom: bool, + ) -> Element<'_, Message> { + let mut rows = column([]).spacing(6); + let list = crate::navigation::gallery_list(appearance, custom); + for chunk in 0..self.family_sections.indices(custom).len().div_ceil(3) { let content = widget::responsive(move |size| { // Three previews: 200:126 image, caption, and tile padding. let height = ((size.width - 12.) / 3. - 16.).max(0.) * 126. / 200. + 34.; let content: Element<'_, Message> = if self.visible_rows.contains(&(list, chunk)) { - // Rebuild only mounted rows; responsive owns their actual layout width. - self.theme_gallery_row(chunk, appearance) + self.theme_gallery_row(chunk, appearance, custom) } else { widget::Space::new().height(height).width(Length::Fill).into() }; @@ -209,30 +268,51 @@ impl App { .into() }) .height(Length::Shrink); - gallery = gallery.push(content); + rows = rows.push(content); } + gallery::group( - container(gallery).width(Length::Fill), - cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")), - match appearance { - Some(Light) => "Light theme", - Some(Dark) => "Dark theme", - None => "Theme", + container(rows).width(Length::Fill), + cosmic::iced::advanced::widget::Id::new(format!("{list}-gallery")), + if custom { + "Custom themes" + } else { + match appearance { + Some(ferese_config::theme::Appearance::Light) => "Light theme", + Some(ferese_config::theme::Appearance::Dark) => "Dark theme", + None => "Theme", + } }, ) } + pub(super) fn gallery_position( + &self, + id: &str, + appearance: Option, + ) -> Option<(&'static str, usize)> { + let index = self.family_ids.iter().position(|family| family == id)?; + let (custom, position) = self.family_sections.position(index)?; + Some((crate::navigation::gallery_list(appearance, custom), position / 3)) + } + fn theme_gallery_row( &self, chunk: usize, appearance: Option, + custom: bool, ) -> Element<'static, Message> { let palette = visuals::Palette::from_resolved(&self.resolved.presented); let selected_id = visuals::family_selection(&self.draft, appearance); - let families = &self.resolved.families[chunk * 3..self.resolved.families.len().min(chunk * 3 + 3)]; + let indices = self.family_sections.indices(custom); + let families = &indices[chunk * 3..indices.len().min(chunk * 3 + 3)]; + let has_selection = indices + .iter() + .any(|&index| self.resolved.families[index].id == selected_id); let ids = &self.family_ids; let mut tiles = row([]).spacing(6); - for (offset, family) in families.iter().enumerate() { + for (offset, &family_index) in families.iter().enumerate() { + let family = &self.resolved.families[family_index]; let selected = family.id == selected_id; let index = chunk * 3 + offset; let id = family.id.clone(); @@ -241,20 +321,22 @@ impl App { .filter(|a| appearance.is_none_or(|variant| variant == *a)); let tile_id = gallery_id(&id, appearance); let ids = ids.clone(); + let indices = indices.clone(); tiles = tiles.push(gallery::tile( family, gallery::TileOptions { variant: appearance, active, selected, + tab_stop: selected || (!has_selection && index == 0), palette, font: self.font, id: tile_id, }, Message::Family(id, appearance), move |key| { - gallery::neighbor(index, ids.len(), key) - .map(|index| Message::Family(ids[index].clone(), appearance)) + gallery::neighbor(index, indices.len(), key) + .map(|index| Message::Family(ids[indices[index]].clone(), appearance)) }, )); } @@ -272,3 +354,30 @@ pub(super) fn gallery_id( ) -> cosmic::iced::advanced::widget::Id { cosmic::iced::advanced::widget::Id::new(format!("theme-{appearance:?}-{id}")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sections_use_registered_imports_not_id_prefixes_or_catalog_position() { + let snapshot = crate::store::Snapshot::parse( + "theme { custom-themes { sunset { file \"sunset.kdl\"; }; dracula { file \"mine.kdl\"; }; }; }".into(), + ) + .unwrap(); + let mut families = ferese_config::families::builtins(); + let mut imported = families[0].clone(); + imported.id = "sunset".into(); + families.insert(1, imported.clone()); + imported.id = "dracula".into(); + families.push(imported); + let mut legacy = families[0].clone(); + legacy.id = "monokai".into(); + families.push(legacy); + let sections = FamilySections::new(&families, &snapshot); + assert_eq!(sections.custom.as_slice(), &[1, 7]); + assert_eq!(sections.built_in.as_slice(), &[0, 2, 3, 4, 5, 6, 8]); + assert_eq!(sections.position(7), Some((true, 1))); + assert_eq!(sections.position(8), Some((false, 6))); + } +}