Reduce Settings page and config reload overhead

Mount nearby shortcut and theme rows, preserve gallery keyboard navigation, reset page scrolling, and reuse native themes during palette transitions. Prepare and deduplicate live config reloads on a worker and apply only changed runtime sections.

Align pointer focus and overview selection on overlapping outputs, add regression coverage, and clean up workspace Clippy warnings.
This commit is contained in:
Samuel Onoja committed 2026-10-02 23:10:01 +01:00
1 parent 629414e0fb
commit 647eaf4ad9
23 files changed
+1098 -220

No files matched your search

@@ -142,7 +142,7 @@ impl App {
}
}
Input::Answer(id, accepted) => {
if !self.connections.prompt.as_ref().is_some_and(|p| p.id == id) {
if self.connections.prompt.as_ref().is_none_or(|p| p.id != id) {
return Task::none();
}
if accepted
+239 -10
View File
@@ -1,5 +1,6 @@
mod connections;
mod form_controls;
mod navigation;
mod pages;
mod theme_controls;
@@ -83,6 +84,8 @@ enum Message {
ThemeChanged(Box<ferese_config::theme::Snapshot>),
DragWindow,
Page(Page),
PagePresented(Page),
RowVisibility(&'static str, usize, bool),
Search(String),
Change(Edit),
SelectFont(String, String),
@@ -136,12 +139,18 @@ struct App {
error: Option<String>,
status: String,
page: Page,
profile_pages: bool,
page_transition: Option<std::time::Instant>,
visible_rows: std::collections::HashSet<(&'static str, usize)>,
hidden_binding_rows: std::collections::HashSet<usize>,
search: String,
inputs: HashMap<String, String>,
ranges: HashMap<String, f64>,
new_command: String,
font: cosmic::font::Font,
native_palette: visuals::Palette,
family_ids: std::sync::Arc<Vec<String>>,
gallery_focus: Option<(String, Option<ferese_config::theme::Appearance>)>,
resolved: ferese_config::theme::Snapshot,
undo_revision: u64,
auto_details: bool,
@@ -153,6 +162,19 @@ struct App {
thumbnail_error: Option<String>,
}
fn initial_visible_rows() -> std::collections::HashSet<(&'static str, usize)> {
// Populate the first viewport before sensors report precise visibility.
// This avoids a blank first frame when a page opens.
(0..4)
.map(|index| ("bindings", index))
.chain(
["theme", "theme-light", "theme-dark"]
.into_iter()
.flat_map(|list| (0..3).map(move |index| (list, index))),
)
.collect()
}
impl Drop for App {
fn drop(&mut self) {
if let Some(client) = &self.connections.client {
@@ -219,12 +241,18 @@ impl cosmic::Application for App {
} else {
Page::Appearance
},
profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(),
page_transition: None,
search: String::new(),
visible_rows: initial_visible_rows(),
hidden_binding_rows: Default::default(),
inputs: HashMap::new(),
ranges: HashMap::new(),
new_command: String::new(),
font,
native_palette,
gallery_focus: None,
family_ids: std::sync::Arc::new(resolved.families.iter().map(|family| family.id.clone()).collect()),
resolved,
undo_revision: 0,
auto_details: false,
@@ -285,8 +313,17 @@ impl cosmic::Application for App {
return self.edit_many(displays::edits(&prefix, mode, automatic));
}
Message::ThemeChanged(snapshot) => {
let font_changed = self.resolved.presented.tokens.typography.font_family
!= snapshot.presented.tokens.typography.font_family;
if self.resolved.families != snapshot.families {
self.family_ids =
std::sync::Arc::new(snapshot.families.iter().map(|family| family.id.clone()).collect());
}
self.resolved = *snapshot;
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
if font_changed {
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
}
return self.update_theme();
}
Message::NoteAction(id, action) => {
@@ -396,9 +433,21 @@ impl cosmic::Application for App {
self.error = Some(error);
}
Message::Page(page) => {
if self.page == page && self.search.is_empty() {
return Task::none();
}
self.page_transition = self.profile_pages.then(std::time::Instant::now);
let leaving = self.page == Page::Connections && page != Page::Connections;
self.page = page;
self.search.clear();
self.visible_rows = initial_visible_rows();
self.hidden_binding_rows.clear();
self.gallery_focus = None;
let reset = cosmic::iced::widget::scrollable::snap_to(
widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::RelativeOffset::START.into(),
);
if leaving {
let cleanup = self.leave_connections();
let load = match page {
@@ -406,16 +455,62 @@ impl cosmic::Application for App {
Page::Wallpaper => self.load_thumbnail(),
_ => Task::none(),
};
return Task::batch([cleanup, load]);
return Task::batch([reset, cleanup, load]);
}
if page == Page::Connections {
return self.refresh_connections();
return Task::batch([reset, self.refresh_connections()]);
}
if page == Page::Displays {
return self.load_displays();
return Task::batch([reset, self.load_displays()]);
}
if page == Page::Wallpaper {
return self.load_thumbnail();
return Task::batch([reset, self.load_thumbnail()]);
}
return reset;
}
Message::PagePresented(page) => {
if self.page == page
&& let Some(started) = self.page_transition.take()
{
eprintln!(
"settings page={page:?} first_redraw_us={}",
started.elapsed().as_micros()
);
}
}
Message::RowVisibility(list, index, visible) => {
if visible {
self.visible_rows.insert((list, index));
if list == "bindings" {
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)
{
let (id, appearance) = self.gallery_focus.take().unwrap();
return cosmic::iced::advanced::widget::operate(
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
)
.map(cosmic::Action::App);
}
} else {
let dirty = list == "bindings"
&& self
.inputs
.keys()
.any(|path| path.starts_with(&format!("bindings.{index}.")));
if dirty {
// Retain the input widget until its pending edit commits.
self.hidden_binding_rows.insert(index);
} else {
self.visible_rows.remove(&(list, index));
}
}
}
Message::Search(query) => self.search = query,
@@ -437,6 +532,20 @@ impl cosmic::Application for App {
}
Message::Commit(field) => {
if let Some(value) = self.inputs.remove(&field.path) {
if field.path.starts_with("bindings.")
&& let Some(index) = field
.path
.split('.')
.nth(1)
.and_then(|index| index.parse::<usize>().ok())
&& !self
.inputs
.keys()
.any(|path| path.starts_with(&format!("bindings.{index}.")))
&& self.hidden_binding_rows.remove(&index)
{
self.visible_rows.remove(&("bindings", index));
}
let edit = if matches!(field.kind, Kind::Text { argv: true, .. }) {
match shlex::split(&value).filter(|v| !v.is_empty()) {
Some(args) => set(
@@ -520,11 +629,29 @@ 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)) {
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))
.map(cosmic::Action::App)
} else {
Task::none()
};
let edits = visuals::choose_family(&id, appearance);
let focus = cosmic::iced::advanced::widget::operate(
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
);
return Task::batch([self.edit_many(edits), focus.map(cosmic::Action::App)]);
let focus = if self.gallery_focus.is_none() {
cosmic::iced::advanced::widget::operate(
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
)
.map(cosmic::Action::App)
} else {
Task::none()
};
return Task::batch([self.edit_many(edits), reveal, focus]);
}
Message::AutoDetails(enabled) => self.auto_details = enabled,
Message::AdvancedTheme(enabled) => self.advanced_theme = enabled,
@@ -716,7 +843,9 @@ impl cosmic::Application for App {
impl App {
fn update_theme(&mut self) -> Task<Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
// Widgets use presented colors directly; COSMIC's structural theme only
// changes when the resolved destination changes, not on every fade tick.
let palette = visuals::Palette::from_resolved(&self.resolved.theme);
if palette == self.native_palette {
return Task::none();
}
@@ -901,6 +1030,106 @@ mod tests {
.0
}
fn shortcuts(count: usize) -> App {
let mut app = app();
app.page = Page::Shortcuts;
let source = (0..count)
.map(|index| format!("binding \"Super+F{index}\" \"none\"\n"))
.collect::<String>();
app.draft = Snapshot::parse(source).unwrap();
assert_eq!(app.draft.records("bindings"), count);
app
}
fn tree_nodes(tree: &cosmic::iced::advanced::widget::Tree) -> usize {
1 + tree.children.iter().map(tree_nodes).sum::<usize>()
}
#[test]
fn shortcuts_build_controls_only_for_mounted_rows() {
let mut app = shortcuts(100);
app.visible_rows.extend((0..4).map(|index| ("bindings", index)));
let visible = tree_nodes(&cosmic::iced::advanced::widget::Tree::new(app.page_view().as_widget()));
app.visible_rows.extend((0..100).map(|index| ("bindings", index)));
let eager = tree_nodes(&cosmic::iced::advanced::widget::Tree::new(app.page_view().as_widget()));
assert!(eager > visible + 96 * 10, "visible={visible}, eager={eager}");
}
#[test]
fn virtual_rows_keep_unsaved_text_mounted_until_unfocus() {
let mut app = shortcuts(10);
let _ = app.update(Message::RowVisibility("bindings", 5, true));
let _ = app.update(Message::Draft("bindings.5.argument".into(), "my command".into()));
let _ = app.update(Message::RowVisibility("bindings", 5, false));
assert!(app.visible_rows.contains(&("bindings", 5)));
assert_eq!(app.inputs["bindings.5.argument"], "my command");
let _ = app.update(Message::Commit(schema::text("bindings.5.argument", "Argument", "", "")));
assert!(!app.visible_rows.contains(&("bindings", 5)));
assert_eq!(app.draft.string("bindings.5.argument", ""), "my command");
}
#[test]
fn navigation_resets_scroll_but_reselecting_the_same_page_does_not() {
let mut app = app();
app.visible_rows.insert(("theme", 2));
let task = app.update(Message::Page(Page::Shortcuts));
assert!(task.units() > 0);
assert_eq!(app.visible_rows, initial_visible_rows());
assert_eq!(app.update(Message::Page(Page::Shortcuts)).units(), 0);
}
#[test]
fn offscreen_theme_selection_defers_focus_until_the_row_is_mounted() {
let mut app = app();
let id = app.family_ids.last().unwrap().clone();
let row = (app.family_ids.len() - 1) / 3;
app.visible_rows.remove(&("theme", row));
let _ = app.update(Message::Family(id.clone(), None));
assert_eq!(app.gallery_focus, Some((id, None)));
assert!(app.visible_rows.contains(&("theme", row)));
let task = app.update(Message::RowVisibility("theme", row, true));
assert!(task.units() > 0);
assert!(app.gallery_focus.is_none());
}
#[test]
fn animated_palette_does_not_rebuild_native_theme_or_family_navigation() {
let mut app = app();
app.native_palette = visuals::Palette::from_resolved(&app.resolved.theme);
let palette = app.native_palette;
let ids = app.family_ids.clone();
let mut snapshot = app.resolved.clone();
snapshot.presented.tokens.colors.surface_base = "#123456".into();
let task = app.update(Message::ThemeChanged(Box::new(snapshot)));
assert_eq!(task.units(), 0);
assert_eq!(app.native_palette, palette);
assert!(std::sync::Arc::ptr_eq(&ids, &app.family_ids));
}
#[test]
#[ignore = "release timing sample; run with --ignored --nocapture"]
fn profile_shortcuts_page_construction() {
for count in [50, 200, 500] {
let mut app = shortcuts(count);
for mounted in [4, count] {
app.visible_rows = (0..mounted).map(|index| ("bindings", index)).collect();
let mut samples = Vec::new();
for _ in 0..100 {
let started = std::time::Instant::now();
let view = app.page_view();
std::hint::black_box(cosmic::iced::advanced::widget::Tree::new(view.as_widget()));
samples.push(started.elapsed());
}
samples.sort_unstable();
eprintln!(
"bindings={count} mounted={mounted} construction_median_us={} p95_us={}",
samples[50].as_micros(),
samples[95].as_micros()
);
}
}
}
#[test]
fn disabling_auto_keeps_the_effective_appearance() {
for appearance in [
+127
View File
@@ -0,0 +1,127 @@
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_row_id(list: &str, row: usize) -> Id {
Id::new(format!("{list}-row-{row}"))
}
pub(super) struct RevealRow {
target: Id,
bounds: Option<Rectangle>,
}
impl RevealRow {
pub(super) fn new(list: &str, row: usize) -> Self {
Self {
target: gallery_row_id(list, row),
bounds: None,
}
}
}
impl<M> Operation<M> for RevealRow {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation<M>)) {
operate(self);
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
if id == Some(&self.target) {
self.bounds = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
viewport: Rectangle,
_: Rectangle,
translation: Vector,
state: &mut dyn Scrollable,
) {
if id != Some(&Id::new("settings-content")) {
return;
}
if let Some(target) = self.bounds {
let visible = target.y - translation.y;
let delta = if visible < viewport.y {
visible - viewport.y
} else if visible + target.height > viewport.y + viewport.height {
visible + target.height - viewport.y - viewport.height
} else {
return;
};
state.scroll_to(cosmic::iced::widget::scrollable::AbsoluteOffset {
x: None,
y: Some((translation.y + delta).max(0.)),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::widget::operation::scrollable::{AbsoluteOffset, RelativeOffset};
#[derive(Default)]
struct Scroll(Option<AbsoluteOffset<Option<f32>>>);
impl Scrollable for Scroll {
fn snap_to(&mut self, _: RelativeOffset<Option<f32>>) {
panic!("not a snap");
}
fn scroll_to(&mut self, offset: AbsoluteOffset<Option<f32>>) {
self.0 = Some(offset);
}
fn scroll_by(&mut self, _: AbsoluteOffset, _: Rectangle, _: Rectangle) {
panic!("not a relative scroll");
}
}
#[test]
fn wrapping_and_home_end_reveal_only_as_much_as_needed() {
let viewport = Rectangle {
x: 0.,
y: 40.,
width: 600.,
height: 500.,
};
for (top, height, offset, expected) in [
(1000., 200., 0., Some(660.)),
(40., 200., 500., Some(0.)),
(100., 200., 0., None),
] {
let mut operation = RevealRow::new("theme", 10);
let target = operation.target.clone();
Operation::<()>::container(
&mut operation,
Some(&target),
Rectangle {
y: top,
height,
..viewport
},
);
let mut state = Scroll::default();
Operation::<()>::scrollable(
&mut operation,
Some(&Id::new("settings-content")),
viewport,
viewport,
Vector::new(0., offset),
&mut state,
);
assert_eq!(state.0.and_then(|offset| offset.y), expected);
}
}
}
+50 -25
View File
@@ -396,28 +396,44 @@ impl App {
body = body.push(gestures);
for index in 0..self.draft.records("bindings") {
let prefix = format!("bindings.{index}");
let group = column([])
.spacing(1)
.push(self.field(schema::text(
format!("{prefix}.keys"),
"Shortcut",
"For example: Super+Return or Swipe3Up",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.action"),
"Action",
"Action such as toggle-overview, spawn, focus, move, or none.",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.argument"),
"Argument",
"Command name, direction, or action argument.",
"",
)));
body = body.push(container(group).padding(4).class(visuals::surface(palette.card, 14.)));
let content: Element<'static, Message> = if self.visible_rows.contains(&("bindings", index)) {
let prefix = format!("bindings.{index}");
let group = column([])
.spacing(1)
.push(self.field(schema::text(
format!("{prefix}.keys"),
"Shortcut",
"Keys or swipe, such as Super+Return",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.action"),
"Action",
"Action name, such as spawn or focus",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.argument"),
"Argument",
"Command, direction, or action argument",
"",
)));
container(group)
.padding(4)
.height(164)
.class(visuals::surface(palette.card, 14.))
.into()
} else {
widget::Space::new().height(164).width(Length::Fill).into()
};
body = body.push(
cosmic::iced::widget::sensor(content)
.key(("bindings", index))
.anticipate(200)
.on_show(move |_| Message::RowVisibility("bindings", index, true))
.on_hide(Message::RowVisibility("bindings", index, false)),
);
}
if self.draft.records("bindings") == 0 {
body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload."));
@@ -445,7 +461,7 @@ impl App {
content = content.push(
scrollable(
container(body)
container(cosmic::iced::widget::keyed_column([(self.page as usize, body.into())]))
.padding(cosmic::iced::Padding {
top: 0.,
right: 12.,
@@ -507,7 +523,7 @@ impl App {
} else {
footer.into()
};
row([])
let view: Element<'_, Message> = row([])
.push(sidebar)
.push(
container(
@@ -527,6 +543,15 @@ impl App {
.height(Length::Fill)
.class(visuals::surface(palette.background, 0.)),
)
.into()
.into();
if self.profile_pages {
let page = self.page;
cosmic::iced::widget::sensor(view)
.key((page as usize, self.search.is_empty()))
.on_show(move |_| Message::PagePresented(page))
.into()
} else {
view
}
}
}
+69 -40
View File
@@ -156,15 +156,9 @@ impl App {
.into()
}
pub(super) fn theme_gallery(
&self,
appearance: Option<ferese_config::theme::Appearance>,
) -> Element<'static, Message> {
pub(super) fn theme_gallery(&self, appearance: Option<ferese_config::theme::Appearance>) -> Element<'_, Message> {
use ferese_config::theme::Appearance::{Dark, Light};
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let selected_id = visuals::family_selection(&self.draft, appearance);
let choices = self.resolved.families.clone();
let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect();
let choices = &self.resolved.families;
let mut heading = row([]).align_y(Alignment::Center).spacing(6).push(
self.label(
match appearance {
@@ -192,38 +186,30 @@ impl App {
));
}
let mut gallery = column([]).spacing(6).push(heading);
for (chunk, families) in choices.chunks(3).enumerate() {
let mut tiles = row([]).spacing(6);
for (offset, family) in families.iter().enumerate() {
let selected = family.id == selected_id;
let index = chunk * 3 + offset;
let id = family.id.clone();
let active = selected
.then_some(self.resolved.theme.appearance)
.filter(|a| appearance.is_none_or(|variant| variant == *a));
let ids = ids.clone();
let tile_id = gallery_id(&id, appearance);
tiles = tiles.push(gallery::tile(
family,
gallery::TileOptions {
variant: appearance,
active,
selected,
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))
},
));
}
for _ in families.len()..3 {
tiles = tiles.push(widget::Space::new().width(Length::Fill));
}
gallery = gallery.push(tiles);
let list = crate::navigation::gallery_list(appearance);
for chunk in 0..choices.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)
} else {
widget::Space::new().height(height).width(Length::Fill).into()
};
cosmic::iced::widget::sensor(
container(content)
.height(height)
.id(crate::navigation::gallery_row_id(list, chunk)),
)
.key((list, chunk))
.anticipate(200)
.on_show(move |_| Message::RowVisibility(list, chunk, true))
.on_hide(Message::RowVisibility(list, chunk, false))
.into()
})
.height(Length::Shrink);
gallery = gallery.push(content);
}
gallery::group(
container(gallery).width(Length::Fill),
@@ -235,6 +221,49 @@ impl App {
},
)
}
fn theme_gallery_row(
&self,
chunk: usize,
appearance: Option<ferese_config::theme::Appearance>,
) -> 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 ids = &self.family_ids;
let mut tiles = row([]).spacing(6);
for (offset, family) in families.iter().enumerate() {
let selected = family.id == selected_id;
let index = chunk * 3 + offset;
let id = family.id.clone();
let active = selected
.then_some(self.resolved.theme.appearance)
.filter(|a| appearance.is_none_or(|variant| variant == *a));
let tile_id = gallery_id(&id, appearance);
let ids = ids.clone();
tiles = tiles.push(gallery::tile(
family,
gallery::TileOptions {
variant: appearance,
active,
selected,
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))
},
));
}
for _ in families.len()..3 {
tiles = tiles.push(widget::Space::new().width(Length::Fill));
}
tiles.into()
}
}
pub(super) fn gallery_id(