Group imported themes in a custom gallery
This commit is contained in:
1 parent
5c321addec
commit
f81d8d50ea
4 files changed
+211
-38
No files matched your search
@@ -11,6 +11,7 @@ pub struct TileOptions {
|
||||
pub variant: Option<Appearance>,
|
||||
pub active: Option<Appearance>,
|
||||
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<M: Clone + 'static>(
|
||||
variant,
|
||||
active,
|
||||
selected,
|
||||
tab_stop,
|
||||
palette,
|
||||
font,
|
||||
id,
|
||||
@@ -61,7 +63,7 @@ pub fn tile<M: Clone + 'static>(
|
||||
.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<Navigation<'a, M>>,
|
||||
@@ -257,11 +260,22 @@ pub fn radio<'a, M: Clone + 'a>(
|
||||
id: Id,
|
||||
selected: bool,
|
||||
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + '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<Element<'a, M>>,
|
||||
id: Id,
|
||||
selected: bool,
|
||||
tab_stop: bool,
|
||||
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + '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<Element<'a, M>>, id: Id, labe
|
||||
content: content.into(),
|
||||
id,
|
||||
selected: false,
|
||||
tab_stop: false,
|
||||
group: true,
|
||||
label: label.into(),
|
||||
navigate: None,
|
||||
@@ -305,7 +320,7 @@ impl<M: Clone> Widget<M, Theme, Renderer> 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::<Focus>());
|
||||
} else {
|
||||
tree.state.downcast_mut::<Focus>().0 = false;
|
||||
@@ -339,10 +354,10 @@ impl<M: Clone> Widget<M, Theme, Renderer> for Radio<'_, M> {
|
||||
}
|
||||
}
|
||||
if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))) {
|
||||
tree.state.downcast_mut::<Focus>().0 = self.selected && cursor.is_over(layout.bounds());
|
||||
tree.state.downcast_mut::<Focus>().0 = self.tab_stop && cursor.is_over(layout.bounds());
|
||||
}
|
||||
}
|
||||
if !self.group && self.selected && tree.state.downcast_ref::<Focus>().0 {
|
||||
if !self.group && self.tab_stop && tree.state.downcast_ref::<Focus>().0 {
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(key),
|
||||
..
|
||||
@@ -392,7 +407,7 @@ impl<M: Clone> Widget<M, Theme, Renderer> 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::<Focus>().0 {
|
||||
if !self.group && self.tab_stop && tree.state.downcast_ref::<Focus>().0 {
|
||||
use renderer::Renderer as _;
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
|
||||
@@ -150,6 +150,7 @@ struct App {
|
||||
font: cosmic::font::Font,
|
||||
native_palette: visuals::Palette,
|
||||
family_ids: std::sync::Arc<Vec<String>>,
|
||||
family_sections: theme_controls::FamilySections,
|
||||
gallery_focus: Option<(String, Option<ferese_config::theme::Appearance>)>,
|
||||
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::<String>();
|
||||
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();
|
||||
|
||||
@@ -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<ferese_config::theme::Appearance>) -> &'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<ferese_config::theme::Appearance>, 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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,39 @@ use super::{
|
||||
schema, set, text_input, visuals, widget,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct FamilySections {
|
||||
built_in: std::sync::Arc<Vec<usize>>,
|
||||
custom: std::sync::Arc<Vec<usize>>,
|
||||
}
|
||||
|
||||
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<Vec<usize>> {
|
||||
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<ferese_config::theme::Appearance>) -> 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) {
|
||||
if self.undo.is_some() {
|
||||
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),
|
||||
));
|
||||
}
|
||||
heading = heading.push(self.settings_button(
|
||||
let mut custom_heading = row([])
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(6)
|
||||
.push(self.label("Custom themes", 14.).width(Length::Fill));
|
||||
if appearance != Some(Dark) {
|
||||
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<ferese_config::theme::Appearance>,
|
||||
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:?}")),
|
||||
container(rows).width(Length::Fill),
|
||||
cosmic::iced::advanced::widget::Id::new(format!("{list}-gallery")),
|
||||
if custom {
|
||||
"Custom themes"
|
||||
} else {
|
||||
match appearance {
|
||||
Some(Light) => "Light theme",
|
||||
Some(Dark) => "Dark theme",
|
||||
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<ferese_config::theme::Appearance>,
|
||||
) -> 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<ferese_config::theme::Appearance>,
|
||||
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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user