perf(settings): coalesce refreshes and cache resize previews
This commit is contained in:
1 parent
6671960d5e
commit
81b99709bb
16 files changed
+906
-74
No files matched your search
@@ -354,7 +354,7 @@ fn load_schema() -> Result<Value, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog() -> Result<Arc<Value>, String> {
|
||||
pub(super) fn catalog() -> Result<Arc<Value>, String> {
|
||||
static CATALOG: std::sync::OnceLock<Arc<Value>> = std::sync::OnceLock::new();
|
||||
if let Some(catalog) = CATALOG.get() {
|
||||
return Ok(catalog.clone());
|
||||
|
||||
@@ -27,6 +27,7 @@ mod tests {
|
||||
"/unused/calendar.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
|
||||
@@ -271,6 +271,7 @@ mod tests {
|
||||
"/unused/connections-test.kdl".into(),
|
||||
crate::store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0
|
||||
@@ -347,6 +348,7 @@ mod tests {
|
||||
Some(crate::InitialPage::Connections(
|
||||
super::super::Tab::Bluetooth,
|
||||
)),
|
||||
None,
|
||||
),
|
||||
);
|
||||
assert_eq!(app.page, crate::schema::Page::Connections);
|
||||
|
||||
@@ -33,6 +33,36 @@ pub(crate) struct Snapshot {
|
||||
pub bluetooth: Result<bluetooth::Bluetooth, String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn performance_snapshot() -> Snapshot {
|
||||
Snapshot {
|
||||
wifi: Ok(wifi::Wifi {
|
||||
enabled: true,
|
||||
hardware_enabled: true,
|
||||
radios: vec![wifi::Radio {
|
||||
path: "/test/wlan0".into(),
|
||||
name: "Test Wi-Fi".into(),
|
||||
}],
|
||||
networks: (0..12)
|
||||
.map(|i| Network {
|
||||
ssid: format!("Network {i}").into_bytes(),
|
||||
name: format!("Network {i}"),
|
||||
security: Security::Personal,
|
||||
strength: 90 - i,
|
||||
device: "/test/wlan0".into(),
|
||||
ap: format!("/test/ap{i}"),
|
||||
saved: None,
|
||||
connected: i == 0,
|
||||
connecting: false,
|
||||
hidden: false,
|
||||
})
|
||||
.collect(),
|
||||
saved: Vec::new(),
|
||||
}),
|
||||
bluetooth: Ok(bluetooth::Bluetooth::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct State {
|
||||
pub client: Option<Arc<Client>>,
|
||||
pub snapshot: Option<Snapshot>,
|
||||
|
||||
@@ -9,6 +9,7 @@ mod panel_drag;
|
||||
mod panel_edit;
|
||||
mod panel_inspector;
|
||||
mod panel_preview;
|
||||
mod refresh;
|
||||
mod theme_controls;
|
||||
mod tray_controls;
|
||||
|
||||
@@ -22,7 +23,10 @@ mod store;
|
||||
mod visuals;
|
||||
mod wallpaper_controls;
|
||||
mod watch;
|
||||
mod window_preview;
|
||||
|
||||
#[cfg(test)]
|
||||
mod performance_tests;
|
||||
#[cfg(test)]
|
||||
mod scroll_tests;
|
||||
|
||||
@@ -91,7 +95,8 @@ fn main() -> cosmic::iced::Result {
|
||||
if let Ok(snapshot) = &initial {
|
||||
ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc));
|
||||
}
|
||||
let theme = visuals::native_theme(initial.as_ref().ok());
|
||||
let resolved = ferese_theme_client::service::current();
|
||||
let theme = visuals::Palette::from_resolved(&resolved.presented).native_theme();
|
||||
|
||||
cosmic::app::run::<App>(
|
||||
Settings::default()
|
||||
@@ -99,7 +104,7 @@ fn main() -> cosmic::iced::Result {
|
||||
.is_daemon(false)
|
||||
.antialiasing(true)
|
||||
.default_text_size(14.),
|
||||
(path, initial, initial_page),
|
||||
(path, initial, initial_page, Some(resolved)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,7 +121,7 @@ enum Message {
|
||||
Options(all_options::Input),
|
||||
LoadOptions,
|
||||
OptionsLoaded(Result<std::sync::Arc<serde_json::Value>, String>),
|
||||
TrayApps(Vec<tray_controls::Application>),
|
||||
TrayApps(Result<Vec<tray_controls::Application>, String>),
|
||||
RefreshTrayApps,
|
||||
ReadRenderScale(cosmic::iced::window::Id),
|
||||
RenderScale(f32),
|
||||
@@ -128,6 +133,7 @@ enum Message {
|
||||
SelectDisplay(String),
|
||||
DisplayResolution(String, displays::Resolution),
|
||||
RefreshDisplays,
|
||||
PollDisplays,
|
||||
DisplaysLoaded(Result<Vec<displays::Display>, String>),
|
||||
RefreshRate(String, displays::Mode, bool),
|
||||
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
||||
@@ -153,6 +159,7 @@ enum Message {
|
||||
Release(Field),
|
||||
Saved(Result<(Snapshot, bool), String>),
|
||||
Reload,
|
||||
Reloaded(u64, Result<Snapshot, String>),
|
||||
ExternalConfig(Result<Snapshot, String>),
|
||||
Undo,
|
||||
Family(String, Option<ferese_config::theme::Appearance>),
|
||||
@@ -188,9 +195,11 @@ enum Message {
|
||||
|
||||
struct App {
|
||||
tray_apps: Vec<tray_controls::Application>,
|
||||
tray_refresh: refresh::Gate,
|
||||
options: all_options::State,
|
||||
connections: connections::State,
|
||||
displays: Vec<displays::Display>,
|
||||
display_refresh: refresh::Gate,
|
||||
display_selection: Option<String>,
|
||||
display_error: Option<String>,
|
||||
core: Core,
|
||||
@@ -200,6 +209,8 @@ struct App {
|
||||
draft: Snapshot,
|
||||
pending: Vec<Edit>,
|
||||
saving: bool,
|
||||
reloading: bool,
|
||||
edit_revision: u64,
|
||||
undo: Option<String>,
|
||||
saving_previous: String,
|
||||
error: Option<String>,
|
||||
@@ -261,7 +272,12 @@ impl Drop for App {
|
||||
|
||||
impl cosmic::Application for App {
|
||||
type Executor = cosmic::executor::Default;
|
||||
type Flags = (PathBuf, Result<Snapshot, String>, Option<InitialPage>);
|
||||
type Flags = (
|
||||
PathBuf,
|
||||
Result<Snapshot, String>,
|
||||
Option<InitialPage>,
|
||||
Option<ferese_ipc::theme::Snapshot>,
|
||||
);
|
||||
type Message = Message;
|
||||
const APP_ID: &'static str = "dev.ferese.Settings";
|
||||
|
||||
@@ -273,7 +289,10 @@ impl cosmic::Application for App {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
fn init(mut core: Core, (path, initial, initial_page): Self::Flags) -> (Self, Task<Message>) {
|
||||
fn init(
|
||||
mut core: Core,
|
||||
(path, initial, initial_page, resolved): Self::Flags,
|
||||
) -> (Self, Task<Message>) {
|
||||
core.window.show_headerbar = false;
|
||||
|
||||
let status = if path == store::config_path() {
|
||||
@@ -286,7 +305,7 @@ impl cosmic::Application for App {
|
||||
let current = initial.unwrap_or_else(|_| {
|
||||
Snapshot::parse(String::new()).expect("an empty Settings document uses valid defaults")
|
||||
});
|
||||
let resolved = ferese_theme_client::service::current();
|
||||
let resolved = resolved.unwrap_or_else(ferese_theme_client::service::current);
|
||||
let font = fonts::interface_font(&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);
|
||||
@@ -294,6 +313,7 @@ impl cosmic::Application for App {
|
||||
let options = all_options::State::pending(¤t);
|
||||
let mut app = Self {
|
||||
tray_apps: Vec::new(),
|
||||
tray_refresh: Default::default(),
|
||||
options,
|
||||
connections: connections::State {
|
||||
tab: match initial_page {
|
||||
@@ -303,6 +323,7 @@ impl cosmic::Application for App {
|
||||
..connections::State::default()
|
||||
},
|
||||
displays: vec![],
|
||||
display_refresh: Default::default(),
|
||||
display_selection: None,
|
||||
display_error: None,
|
||||
core,
|
||||
@@ -312,6 +333,8 @@ impl cosmic::Application for App {
|
||||
current,
|
||||
pending: vec![],
|
||||
saving: false,
|
||||
reloading: false,
|
||||
edit_revision: 0,
|
||||
undo: None,
|
||||
saving_previous: String::new(),
|
||||
error,
|
||||
@@ -394,9 +417,25 @@ impl cosmic::Application for App {
|
||||
}
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// Draft text and editors can change without committing a document edit.
|
||||
// A slow reload must not replace input entered after it was requested.
|
||||
if matches!(
|
||||
message,
|
||||
Message::Draft(..)
|
||||
| Message::Range(..)
|
||||
| Message::Options(_)
|
||||
| Message::CalendarFunction(_)
|
||||
) {
|
||||
self.edit_revision = self.edit_revision.wrapping_add(1);
|
||||
}
|
||||
match message {
|
||||
Message::TrayApps(apps) => self.tray_apps = apps,
|
||||
Message::RefreshTrayApps => return self.load_tray_apps(),
|
||||
Message::TrayApps(apps) => {
|
||||
self.tray_refresh.finish();
|
||||
if let Ok(apps) = apps {
|
||||
self.tray_apps = apps;
|
||||
}
|
||||
}
|
||||
Message::RefreshTrayApps => return self.refresh_tray_apps(true),
|
||||
Message::Options(input) => return self.options_input(input),
|
||||
Message::LoadOptions => return self.load_options(),
|
||||
Message::OptionsLoaded(result) => self.options_loaded(result),
|
||||
@@ -430,14 +469,18 @@ impl cosmic::Application for App {
|
||||
return self.edit_many(displays::edits(&prefix, mode, automatic));
|
||||
}
|
||||
}
|
||||
Message::RefreshDisplays => return self.load_displays(),
|
||||
Message::DisplaysLoaded(result) => match result {
|
||||
Ok(displays) => {
|
||||
self.displays = displays;
|
||||
self.display_error = None;
|
||||
Message::RefreshDisplays => return self.refresh_displays(true),
|
||||
Message::PollDisplays => return self.load_displays(),
|
||||
Message::DisplaysLoaded(result) => {
|
||||
self.display_refresh.finish();
|
||||
match result {
|
||||
Ok(displays) => {
|
||||
self.displays = displays;
|
||||
self.display_error = None;
|
||||
}
|
||||
Err(error) => self.display_error = Some(error),
|
||||
}
|
||||
Err(error) => self.display_error = Some(error),
|
||||
},
|
||||
}
|
||||
Message::RefreshRate(prefix, mode, automatic) => {
|
||||
self.inputs.remove(&format!("{prefix}.mode"));
|
||||
return self.edit_many(displays::edits(&prefix, mode, automatic));
|
||||
@@ -493,6 +536,7 @@ impl cosmic::Application for App {
|
||||
));
|
||||
self.options.reset(&snapshot);
|
||||
self.calendar_editor.refresh(&snapshot);
|
||||
self.edit_revision = self.edit_revision.wrapping_add(1);
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.family_sections = theme_controls::FamilySections::new(
|
||||
@@ -885,29 +929,52 @@ impl cosmic::Application for App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Reload if !self.saving => match Snapshot::read(&self.path) {
|
||||
Ok(snapshot) => {
|
||||
ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc));
|
||||
self.options.reset(&snapshot);
|
||||
self.calendar_editor.refresh(&snapshot);
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.font = fonts::interface_font(
|
||||
&self.resolved.presented.tokens.typography.font_family,
|
||||
);
|
||||
self.pending.clear();
|
||||
self.inputs.clear();
|
||||
self.ranges.clear();
|
||||
self.error = None;
|
||||
self.undo = None;
|
||||
self.status = "Reloaded from your config".into();
|
||||
|
||||
return Task::batch([self.update_theme(), self.load_thumbnail()]);
|
||||
Message::Reload if !self.saving && !self.reloading => {
|
||||
self.reloading = true;
|
||||
let revision = self.edit_revision;
|
||||
let path = self.path.clone();
|
||||
return cosmic::task::future(async move {
|
||||
Message::Reloaded(
|
||||
revision,
|
||||
tokio::task::spawn_blocking(move || Snapshot::read(&path))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string())),
|
||||
)
|
||||
});
|
||||
}
|
||||
Message::Reloaded(revision, result) => {
|
||||
self.reloading = false;
|
||||
if self.saving || revision != self.edit_revision {
|
||||
return Task::none();
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
},
|
||||
match result {
|
||||
Ok(snapshot) => {
|
||||
ferese_config::locale::set(ferese_config::locale::from_document(
|
||||
&snapshot.doc,
|
||||
));
|
||||
self.options.reset(&snapshot);
|
||||
self.calendar_editor.refresh(&snapshot);
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.family_sections = theme_controls::FamilySections::new(
|
||||
&self.resolved.families,
|
||||
&self.draft,
|
||||
);
|
||||
self.font = fonts::interface_font(
|
||||
&self.resolved.presented.tokens.typography.font_family,
|
||||
);
|
||||
self.pending.clear();
|
||||
self.inputs.clear();
|
||||
self.ranges.clear();
|
||||
self.error = None;
|
||||
self.undo = None;
|
||||
self.status = "Reloaded from your config".into();
|
||||
|
||||
return Task::batch([self.update_theme(), self.load_thumbnail()]);
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
}
|
||||
Message::Undo if !self.saving => {
|
||||
if let Some(source) = self.undo.take() {
|
||||
return self.start_save(source);
|
||||
@@ -1189,7 +1256,7 @@ impl cosmic::Application for App {
|
||||
.map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
|
||||
if matches!(self.page, Page::Displays | Page::Bar) {
|
||||
cosmic::iced::time::every(std::time::Duration::from_secs(2))
|
||||
.map(|_| Message::RefreshDisplays)
|
||||
.map(|_| Message::PollDisplays)
|
||||
} else {
|
||||
cosmic::iced::Subscription::none()
|
||||
},
|
||||
@@ -1225,6 +1292,7 @@ impl App {
|
||||
cosmic::command::set_theme(palette.native_theme())
|
||||
}
|
||||
fn edit_many(&mut self, edits: Vec<Edit>) -> Task<Message> {
|
||||
self.edit_revision = self.edit_revision.wrapping_add(1);
|
||||
for edit in edits {
|
||||
if let Err(error) = self.draft.edit(&edit) {
|
||||
self.error = Some(error);
|
||||
@@ -1235,7 +1303,17 @@ impl App {
|
||||
self.flush()
|
||||
}
|
||||
|
||||
fn load_displays(&self) -> Task<Message> {
|
||||
fn load_displays(&mut self) -> Task<Message> {
|
||||
self.refresh_displays(false)
|
||||
}
|
||||
|
||||
fn refresh_displays(&mut self, force: bool) -> Task<Message> {
|
||||
if !self
|
||||
.display_refresh
|
||||
.begin(force, std::time::Duration::from_secs(1))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
cosmic::task::future(async {
|
||||
Message::DisplaysLoaded(
|
||||
tokio::task::spawn_blocking(displays::load)
|
||||
@@ -1246,6 +1324,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn change(&mut self, edit: Edit) -> Task<Message> {
|
||||
self.edit_revision = self.edit_revision.wrapping_add(1);
|
||||
if let Edit::Set(path, value) = &edit
|
||||
&& matches!(
|
||||
path.as_str(),
|
||||
@@ -1307,6 +1386,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn start_save(&mut self, desired: String) -> Task<Message> {
|
||||
self.edit_revision = self.edit_revision.wrapping_add(1);
|
||||
self.saving = true;
|
||||
self.saving_previous = self.current.source.clone();
|
||||
let expected = self.current.source.clone();
|
||||
@@ -1342,11 +1422,108 @@ mod tests {
|
||||
PathBuf::from("/unused/settings-test.kdl"),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshes_coalesce_keep_last_good_data_and_allow_manual_retry() {
|
||||
let mut app = app();
|
||||
assert_eq!(app.load_displays().units(), 1);
|
||||
assert_eq!(app.load_displays().units(), 0);
|
||||
assert_eq!(app.update(Message::RefreshDisplays).units(), 0);
|
||||
drop(
|
||||
app.update(Message::DisplaysLoaded(Ok(vec![displays::Display {
|
||||
connector: "DP-1".into(),
|
||||
identity: "Test display".into(),
|
||||
profile: None,
|
||||
logical_width: Some(1920),
|
||||
focused: true,
|
||||
current: None,
|
||||
modes: vec![],
|
||||
}]))),
|
||||
);
|
||||
assert_eq!(app.load_displays().units(), 0);
|
||||
assert_eq!(app.update(Message::RefreshDisplays).units(), 1);
|
||||
drop(app.update(Message::DisplaysLoaded(Err("disconnected".into()))));
|
||||
assert_eq!(app.displays.len(), 1);
|
||||
assert_eq!(app.display_error.as_deref(), Some("disconnected"));
|
||||
assert_eq!(app.update(Message::RefreshDisplays).units(), 1);
|
||||
|
||||
assert_eq!(app.load_tray_apps().units(), 1);
|
||||
assert_eq!(app.load_tray_apps().units(), 0);
|
||||
assert_eq!(app.update(Message::RefreshTrayApps).units(), 0);
|
||||
drop(
|
||||
app.update(Message::TrayApps(Ok(vec![tray_controls::Application {
|
||||
id: "test.app".into(),
|
||||
title: "Test app".into(),
|
||||
}]))),
|
||||
);
|
||||
assert_eq!(app.load_tray_apps().units(), 0);
|
||||
assert_eq!(app.update(Message::RefreshTrayApps).units(), 1);
|
||||
drop(app.update(Message::TrayApps(Err("timeout".into()))));
|
||||
assert_eq!(app.tray_apps[0].id, "test.app");
|
||||
assert_eq!(app.update(Message::RefreshTrayApps).units(), 1);
|
||||
drop(app.update(Message::TrayApps(Ok(vec![]))));
|
||||
assert!(app.tray_apps.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_uses_the_already_loaded_theme_snapshot() {
|
||||
let mut resolved = ferese_theme_client::service::current();
|
||||
resolved.revision += 123;
|
||||
let expected_revision = resolved.revision;
|
||||
let expected_palette = visuals::Palette::from_resolved(&resolved.presented);
|
||||
let (app, _) = App::init(
|
||||
Core::default(),
|
||||
(
|
||||
"/unused/settings-test.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
Some(resolved),
|
||||
),
|
||||
);
|
||||
assert_eq!(app.resolved.revision, expected_revision);
|
||||
assert_eq!(app.native_palette.accent, expected_palette.accent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_reload_runs_in_a_worker_and_cannot_replace_newer_edits() {
|
||||
let mut app = app();
|
||||
assert_eq!(app.update(Message::Reload).units(), 1);
|
||||
let revision = app.edit_revision;
|
||||
assert!(app.reloading);
|
||||
assert!(app.error.is_none());
|
||||
assert_eq!(app.update(Message::Reload).units(), 0);
|
||||
drop(app.update(Message::Draft("test.field".into(), "new input".into())));
|
||||
drop(app.update(Message::Reloaded(
|
||||
revision,
|
||||
Snapshot::parse("animations { speed 0.5; }".into()),
|
||||
)));
|
||||
assert_eq!(app.inputs["test.field"], "new input");
|
||||
assert!(!app.reloading);
|
||||
assert!(!app.current.source.contains("speed"));
|
||||
|
||||
assert_eq!(app.update(Message::Reload).units(), 1);
|
||||
let revision = app.edit_revision;
|
||||
drop(app.update(Message::Reloaded(
|
||||
revision,
|
||||
Snapshot::parse("animations { speed 0.8; }".into()),
|
||||
)));
|
||||
assert!(app.inputs.is_empty());
|
||||
assert_eq!(app.current.number("animations.speed", 1.), 0.8);
|
||||
assert!(!app.reloading);
|
||||
assert_eq!(app.update(Message::Reload).units(), 1);
|
||||
drop(app.update(Message::Reloaded(
|
||||
app.edit_revision,
|
||||
Err("read failed".into()),
|
||||
)));
|
||||
assert_eq!(app.error.as_deref(), Some("read failed"));
|
||||
assert_eq!(app.current.number("animations.speed", 1.), 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_loading_is_lazy_deduplicated_and_retries_after_failure() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -1025,6 +1025,7 @@ mod tests {
|
||||
"/unused/range.kdl".into(),
|
||||
store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -1117,6 +1118,7 @@ mod tests {
|
||||
"/unused/editor.kdl".into(),
|
||||
store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
|
||||
@@ -4,7 +4,7 @@ use ferese_panel::layout::{Placement, Resolution};
|
||||
use ferese_panel::{Availability, Group, Item, ItemId, ItemKind, Panel, Representation, Zone};
|
||||
use ferese_theme::panel::{Sample, Spacing, frame};
|
||||
|
||||
use crate::{App, Element, Message, panel_controls, visuals};
|
||||
use crate::{App, Element, Message, visuals};
|
||||
|
||||
const SAMPLE_AVAILABILITY: Availability = Availability {
|
||||
tray: true,
|
||||
@@ -104,25 +104,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn preview_panel(&self) -> Result<Panel, String> {
|
||||
let panels = match self.draft.item("panels") {
|
||||
Some(panels) => panels.clone(),
|
||||
None => {
|
||||
let crate::store::Edit::Set(_, panels) = panel_controls::initialize(&self.draft)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
panels
|
||||
}
|
||||
};
|
||||
serde_json::from_value::<Vec<Panel>>(panels)
|
||||
.map_err(|error| error.to_string())?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|mut panel| {
|
||||
panel.consolidate_clock_controls();
|
||||
panel
|
||||
})
|
||||
.ok_or_else(|| "No panel configured.".into())
|
||||
self.draft.preview_panel()
|
||||
}
|
||||
|
||||
fn panel_preview_frame(&self, panel: Panel) -> Element<'_, Message> {
|
||||
@@ -628,6 +610,7 @@ mod tests {
|
||||
"/unused/preview-test.kdl".into(),
|
||||
crate::store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -764,6 +747,7 @@ mod tests {
|
||||
"/unused/preview-centering.kdl".into(),
|
||||
crate::store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -873,6 +857,7 @@ mod tests {
|
||||
"/unused/alignment.kdl".into(),
|
||||
crate::store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -980,6 +965,7 @@ mod tests {
|
||||
"/unused/hover.kdl".into(),
|
||||
crate::store::Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -1083,6 +1069,7 @@ mod tests {
|
||||
"/unused/preview-test.kdl".into(),
|
||||
crate::store::Snapshot::parse("panel main { group-surface unknown; }".into()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Opt-in, reproducible category/resize measurements. No hardware settings change.
|
||||
use super::*;
|
||||
use cosmic::Application;
|
||||
use cosmic::iced::advanced::renderer::{Headless, Renderer as _};
|
||||
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer, widget::Tree};
|
||||
use cosmic::iced::{Color, Event, Font, Pixels, Rectangle, Size, window};
|
||||
|
||||
fn fixture() -> App {
|
||||
let source = std::env::var_os("FERESE_BENCH_CONFIG")
|
||||
.map(|p| std::fs::read_to_string(p).unwrap())
|
||||
.unwrap_or_else(|| include_str!("../../../packaging/config.kdl").into());
|
||||
let (mut app, _) = App::init(
|
||||
Core::default(),
|
||||
(
|
||||
"/unused/settings-performance.kdl".into(),
|
||||
Snapshot::parse(source),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
);
|
||||
app.options_loaded(all_options::catalog());
|
||||
app.connections.snapshot = Some(connections::performance_snapshot());
|
||||
app.tray_apps = (0..12)
|
||||
.map(|i| tray_controls::Application {
|
||||
id: format!("app-{i}"),
|
||||
title: format!("Application {i}"),
|
||||
})
|
||||
.collect();
|
||||
app.displays = (0..3)
|
||||
.map(|i| displays::Display {
|
||||
connector: format!("DP-{}", i + 1),
|
||||
identity: format!("Test display {i}"),
|
||||
profile: None,
|
||||
logical_width: Some(1920),
|
||||
focused: i == 0,
|
||||
current: Some(displays::Mode {
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
refresh: 60000,
|
||||
}),
|
||||
modes: vec![
|
||||
displays::Mode {
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
refresh: 60000,
|
||||
},
|
||||
displays::Mode {
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
refresh: 60000,
|
||||
},
|
||||
],
|
||||
})
|
||||
.collect();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "category/resize profile; requires the matching compositor schema binary"]
|
||||
fn profile_all_categories_and_resizing() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let backend = std::env::var("FERESE_BENCH_BACKEND").unwrap_or_else(|_| "wgpu".into());
|
||||
let mut renderer = runtime
|
||||
.block_on(cosmic::Renderer::new(
|
||||
Font::default(),
|
||||
Pixels(14.),
|
||||
Some(&backend),
|
||||
))
|
||||
.unwrap();
|
||||
let scale = 2.;
|
||||
for page in Page::ALL {
|
||||
let mut app = fixture();
|
||||
drop(app.update(Message::Page(page)));
|
||||
let theme = app.native_palette.native_theme();
|
||||
let mut tree: Option<Tree> = None;
|
||||
for frame in 0..18 {
|
||||
let scenario = if frame == 0 {
|
||||
"cold"
|
||||
} else if frame < 4 {
|
||||
"warm"
|
||||
} else if frame < 14 {
|
||||
"resize"
|
||||
} else {
|
||||
"switch"
|
||||
};
|
||||
if frame >= 14 {
|
||||
drop(app.update(Message::Page(Page::General)));
|
||||
drop(app.update(Message::Page(page)));
|
||||
}
|
||||
let size = if (4..14).contains(&frame) && frame % 2 == 0 {
|
||||
Size::new(631., 568.)
|
||||
} else {
|
||||
Size::new(1272., 760.)
|
||||
};
|
||||
let viewport = Rectangle::with_size(size);
|
||||
let mut messages = Vec::new();
|
||||
let started = std::time::Instant::now();
|
||||
let mut view = app.page_view();
|
||||
let tree = tree.get_or_insert_with(|| Tree::new(&view));
|
||||
cosmic::iced::advanced::widget::tree::NAMED
|
||||
.with(|named| *named.borrow_mut() = tree.take_all_named());
|
||||
tree.diff(view.as_widget_mut());
|
||||
let node = view.as_widget_mut().layout(
|
||||
tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, size),
|
||||
);
|
||||
cosmic::iced::advanced::widget::tree::NAMED.with(|named| named.borrow_mut().clear());
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&Event::Window(window::Event::RedrawRequested(std::time::Instant::now())),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&renderer,
|
||||
&mut clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&viewport,
|
||||
);
|
||||
let layout_us = started.elapsed().as_micros();
|
||||
renderer.reset(viewport);
|
||||
let started = std::time::Instant::now();
|
||||
view.as_widget().draw(
|
||||
tree,
|
||||
&mut renderer,
|
||||
&theme,
|
||||
&renderer::Style {
|
||||
text_color: Color::WHITE,
|
||||
icon_color: Color::WHITE,
|
||||
scale_factor: scale as f64,
|
||||
},
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let draw_us = started.elapsed().as_micros();
|
||||
drop(view);
|
||||
let started = std::time::Instant::now();
|
||||
let pixels = Headless::screenshot(
|
||||
&mut renderer,
|
||||
Size::new((size.width * scale) as u32, (size.height * scale) as u32),
|
||||
scale,
|
||||
theme.cosmic().bg_color().into(),
|
||||
);
|
||||
let render_us = started.elapsed().as_micros();
|
||||
if matches!(frame, 0 | 4 | 5)
|
||||
&& let Some(dir) = std::env::var_os("FERESE_BENCH_IMAGES_DIR")
|
||||
{
|
||||
let path = PathBuf::from(dir);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
image::save_buffer(
|
||||
path.join(format!("{page:?}-{frame}.png")),
|
||||
&pixels,
|
||||
(size.width * scale) as u32,
|
||||
(size.height * scale) as u32,
|
||||
image::ColorType::Rgba8,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for message in messages {
|
||||
drop(app.update(message));
|
||||
}
|
||||
eprintln!(
|
||||
"AUDIT {}",
|
||||
serde_json::json!({"backend":backend,"page":format!("{page:?}"),"frame":frame,"scenario":scenario,"width":size.width,"layout_us":layout_us,"draw_us":draw_us,"render_us":render_us})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Coalesce UI refreshes; changing window size never starts a new read.
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Gate {
|
||||
in_flight: bool,
|
||||
completed: Option<Instant>,
|
||||
}
|
||||
impl Gate {
|
||||
pub fn begin(&mut self, force: bool, max_age: Duration) -> bool {
|
||||
self.begin_at(Instant::now(), force, max_age)
|
||||
}
|
||||
fn begin_at(&mut self, now: Instant, force: bool, max_age: Duration) -> bool {
|
||||
if self.in_flight
|
||||
|| (!force
|
||||
&& self
|
||||
.completed
|
||||
.is_some_and(|at| now.saturating_duration_since(at) < max_age))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.in_flight = true;
|
||||
true
|
||||
}
|
||||
pub fn finish(&mut self) {
|
||||
self.in_flight = false;
|
||||
self.completed = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn concurrent_requests_coalesce_and_cached_visits_do_not_read_again() {
|
||||
let mut gate = Gate::default();
|
||||
let now = Instant::now();
|
||||
let ttl = Duration::from_secs(1);
|
||||
assert!(gate.begin_at(now, false, ttl));
|
||||
assert!(!gate.begin_at(now, true, ttl));
|
||||
gate.in_flight = false;
|
||||
gate.completed = Some(now);
|
||||
assert!(!gate.begin_at(now + Duration::from_millis(500), false, ttl));
|
||||
assert!(gate.begin_at(now + Duration::from_secs(2), false, ttl));
|
||||
gate.finish();
|
||||
assert!(gate.begin_at(Instant::now(), true, ttl));
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ fn panel_preview_stays_visible_while_the_items_editor_scrolls() {
|
||||
"/unused/editor.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -185,6 +186,7 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() {
|
||||
PathBuf::from("/unused/scroll-test.kdl"),
|
||||
Snapshot::parse(source),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -459,6 +461,7 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
||||
"/unused/panel-text.kdl".into(),
|
||||
Snapshot::parse("theme { mode dark; family \"ferese-blue\"; }".into()),
|
||||
Some(InitialPage::Bar),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -652,6 +655,7 @@ fn render_panel_review_states() {
|
||||
"/unused/review.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
Some(InitialPage::Bar),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -899,6 +903,7 @@ fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
|
||||
"/unused/layout.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
Some(InitialPage::Bar),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -1002,6 +1007,7 @@ fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() {
|
||||
"/unused/sidebar.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
Some(InitialPage::Bar),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -1121,6 +1127,7 @@ fn appearance_cards_reflow_without_overlap_and_keep_every_destination() {
|
||||
"/unused/overview.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -1232,6 +1239,7 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() {
|
||||
"/unused/navigation-centers.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -1305,6 +1313,7 @@ fn double_clicking_blank_edges_toggles_maximize_without_changing_settings() {
|
||||
"/unused/edge-gesture.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
Some(InitialPage::Settings(Page::General)),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -1397,6 +1406,7 @@ fn holiday_function_editor_keeps_state_when_focus_and_text_rebuild_the_page() {
|
||||
"/unused/calendar-editor.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
Some(InitialPage::Settings(Page::Calendar)),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
|
||||
@@ -13,6 +13,7 @@ pub const LIMIT: usize = ferese_config::file::LIMIT;
|
||||
pub struct Snapshot {
|
||||
pub source: String,
|
||||
pub doc: Document,
|
||||
preview: Result<std::sync::Arc<ferese_panel::Panel>, String>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
@@ -21,12 +22,19 @@ impl Snapshot {
|
||||
return Err("Configuration exceeds the 60 KiB limit.".into());
|
||||
}
|
||||
let doc = Document::parse(&source).map_err(|e| e.to_string())?;
|
||||
if let Some(value) = doc.get("panels") {
|
||||
let panel = if let Some(value) = doc.get("panels") {
|
||||
let panels: Vec<ferese_panel::Panel> =
|
||||
serde_json::from_value(value.clone()).map_err(|error| error.to_string())?;
|
||||
ferese_panel::validate(&panels)?;
|
||||
}
|
||||
Ok(Self { source, doc })
|
||||
panels.into_iter().next().ok_or("No panel configured.")?
|
||||
} else {
|
||||
ferese_panel::Panel::default()
|
||||
};
|
||||
Ok(Self {
|
||||
source,
|
||||
doc,
|
||||
preview: Ok(Self::normalized(panel)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read(path: &Path) -> Result<Self, String> {
|
||||
@@ -95,8 +103,38 @@ impl Snapshot {
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.source = self.doc.to_string();
|
||||
let path = match edit {
|
||||
Edit::Unset(p) | Edit::Set(p, _) | Edit::Add(p, _) | Edit::Remove(p, _) => p,
|
||||
};
|
||||
if path == "panels" || path.starts_with("panels.") {
|
||||
// Multi-field panel edits can be temporarily incomplete. Cache the
|
||||
// same diagnostic the preview used to produce, without rejecting
|
||||
// intermediate document edits or changing the authored source.
|
||||
self.preview = match self.doc.get("panels") {
|
||||
None => Ok(Self::normalized(ferese_panel::Panel::default())),
|
||||
Some(value) => serde_json::from_value::<Vec<ferese_panel::Panel>>(value.clone())
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|p| {
|
||||
p.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "No panel configured.".into())
|
||||
})
|
||||
.map(Self::normalized),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalized(mut panel: ferese_panel::Panel) -> std::sync::Arc<ferese_panel::Panel> {
|
||||
panel.consolidate_clock_controls();
|
||||
std::sync::Arc::new(panel)
|
||||
}
|
||||
pub fn preview_panel(&self) -> Result<ferese_panel::Panel, String> {
|
||||
self.preview
|
||||
.as_ref()
|
||||
.map(|p| p.as_ref().clone())
|
||||
.map_err(Clone::clone)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -244,6 +282,43 @@ pub fn reload_running() -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn panel_preview_cache_tracks_panel_edits_and_recovers_from_incomplete_edits() {
|
||||
let mut snapshot = Snapshot::parse(String::new()).unwrap();
|
||||
let original = snapshot.preview.as_ref().unwrap().clone();
|
||||
snapshot.edit(&set("animations.speed", 0.8)).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
&original,
|
||||
snapshot.preview.as_ref().unwrap()
|
||||
));
|
||||
|
||||
let mut panel = ferese_panel::Panel::default();
|
||||
panel.geometry.height = Some(42.);
|
||||
snapshot
|
||||
.edit(&set("panels", serde_json::to_value(vec![panel]).unwrap()))
|
||||
.unwrap();
|
||||
assert!(!std::sync::Arc::ptr_eq(
|
||||
&original,
|
||||
snapshot.preview.as_ref().unwrap()
|
||||
));
|
||||
assert_eq!(snapshot.preview_panel().unwrap().geometry.height, Some(42.));
|
||||
snapshot
|
||||
.edit(&set(
|
||||
"panels",
|
||||
serde_json::json!([{"geometry": {"height": 42}}]),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(snapshot.preview_panel().is_err());
|
||||
snapshot.edit(&Edit::Unset("panels".into())).unwrap();
|
||||
let panel = snapshot.preview_panel().unwrap();
|
||||
let mut default = ferese_panel::Panel::default();
|
||||
default.consolidate_clock_controls();
|
||||
assert_eq!(
|
||||
serde_json::to_value(panel).unwrap(),
|
||||
serde_json::to_value(default).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_display_settings_roundtrip_without_changing_rules() {
|
||||
for source in [
|
||||
|
||||
@@ -49,8 +49,23 @@ async fn applications() -> Result<Vec<Application>, String> {
|
||||
Ok(apps)
|
||||
}
|
||||
impl App {
|
||||
pub(super) fn load_tray_apps(&self) -> Task<Message> {
|
||||
cosmic::task::future(async { Message::TrayApps(applications().await.unwrap_or_default()) })
|
||||
pub(super) fn load_tray_apps(&mut self) -> Task<Message> {
|
||||
self.refresh_tray_apps(false)
|
||||
}
|
||||
pub(super) fn refresh_tray_apps(&mut self, force: bool) -> Task<Message> {
|
||||
if !self
|
||||
.tray_refresh
|
||||
.begin(force, std::time::Duration::from_secs(2))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
cosmic::task::future(async {
|
||||
Message::TrayApps(
|
||||
tokio::time::timeout(std::time::Duration::from_secs(3), applications())
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("Tray application query timed out".into())),
|
||||
)
|
||||
})
|
||||
}
|
||||
pub(super) fn tray_application_controls(&self) -> Element<'_, Message> {
|
||||
let hidden = self
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use cosmic::Element;
|
||||
use cosmic::iced::{Color, Length};
|
||||
use cosmic::iced::Color;
|
||||
#[cfg(test)]
|
||||
use cosmic::iced::Length;
|
||||
use cosmic::widget::icon as svg_icon;
|
||||
pub use ferese_theme::Palette;
|
||||
pub const PAGE_TITLE_SIZE: f32 = 16.;
|
||||
@@ -109,6 +111,7 @@ pub fn color(value: &str, fallback: Color) -> Color {
|
||||
ferese_theme::parse_color(value).unwrap_or(fallback)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn hex(c: Color) -> String {
|
||||
format!(
|
||||
"#{:02x}{:02x}{:02x}",
|
||||
@@ -118,10 +121,6 @@ fn hex(c: Color) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn native_theme(_snapshot: Option<&Snapshot>) -> cosmic::Theme {
|
||||
Palette::from_resolved(&ferese_theme_client::service::current().presented).native_theme()
|
||||
}
|
||||
|
||||
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
|
||||
ferese_theme::icons::outline(page.icon(), tint, 19)
|
||||
}
|
||||
@@ -311,11 +310,20 @@ pub fn preview_resolved<'a>(
|
||||
snapshot: &Snapshot,
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
) -> Element<'a, Message> {
|
||||
crate::window_preview::view(snapshot, theme)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn legacy_preview_resolved(
|
||||
snapshot: &Snapshot,
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
) -> Element<'static, Message> {
|
||||
let mut snapshot = snapshot.clone();
|
||||
snapshot.doc = snapshot.doc.with_theme(theme);
|
||||
preview(&snapshot, theme)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn preview(
|
||||
snapshot: &Snapshot,
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
|
||||
@@ -514,6 +514,7 @@ mod tests {
|
||||
"/preview/config.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
@@ -582,6 +583,7 @@ mod tests {
|
||||
"/preview/config.kdl".into(),
|
||||
Snapshot::parse(String::new()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.0;
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! A cached vector scene keeps the existing window/layout illustration without
|
||||
//! repeatedly cloning the configuration or rasterizing a whole SVG on resize.
|
||||
use crate::{Element, Message, Snapshot};
|
||||
use cosmic::iced::advanced::graphics::geometry::{
|
||||
Cache, Frame, LineCap, Path, Renderer as _, Stroke,
|
||||
};
|
||||
use cosmic::iced::advanced::{Layout, Renderer as _, Widget, layout, mouse, renderer, widget};
|
||||
use cosmic::iced::{Color, Length, Point, Rectangle, Size, Vector};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Scene {
|
||||
base: Color,
|
||||
card: Color,
|
||||
accent: Color,
|
||||
muted: Color,
|
||||
gap: f32,
|
||||
radius: f32,
|
||||
bar_y: f32,
|
||||
margin: f32,
|
||||
height: f32,
|
||||
bar_radius: f32,
|
||||
opacity: f32,
|
||||
}
|
||||
impl Scene {
|
||||
fn new(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Self {
|
||||
let p = crate::visuals::Palette::from_resolved(theme);
|
||||
// Match the original SVG's 8-bit, opaque hexadecimal colors.
|
||||
let color =
|
||||
|c: Color| Color::from_rgb8((c.r * 255.) as u8, (c.g * 255.) as u8, (c.b * 255.) as u8);
|
||||
Self {
|
||||
base: color(p.sidebar),
|
||||
card: color(p.card),
|
||||
accent: color(p.accent),
|
||||
muted: color(p.muted),
|
||||
gap: snapshot.number("layout.inner_gap", 8.).clamp(0., 32.) as f32,
|
||||
radius: theme.tokens.geometry.window_radius.clamp(0., 28.) as f32,
|
||||
bar_y: (snapshot.number("panels.0.geometry.edge_margin", 0.) * 0.5 + 12.) as f32,
|
||||
margin: (snapshot.number("panels.0.geometry.side_margins", 0.) * 0.5 + 14.) as f32,
|
||||
height: (snapshot.number("panels.0.geometry.height", 28.) * 0.65) as f32,
|
||||
bar_radius: (theme.tokens.geometry.shell_radius * 0.65) as f32,
|
||||
opacity: if theme.tokens.material.style == "translucent" {
|
||||
theme.tokens.material.opacity.clamp(0., 1.) as f32
|
||||
} else {
|
||||
1.
|
||||
},
|
||||
}
|
||||
}
|
||||
fn paint(&self, frame: &mut Frame<cosmic::Renderer>, scale: f32) {
|
||||
use cosmic::iced::advanced::graphics::geometry::gradient;
|
||||
let rounded = |x, y, w, h, r: f32| {
|
||||
Path::rounded_rectangle(Point::new(x, y), Size::new(w, h), r.into())
|
||||
};
|
||||
let background = rounded(0., 0., 680., 210., 14.);
|
||||
frame.fill(&background, self.base);
|
||||
frame.fill(
|
||||
&background,
|
||||
// SVG's objectBoundingBox gradient projects in a unit square before
|
||||
// its non-uniform scale to 680 x 210. Preserve that projection.
|
||||
gradient::Linear::new(
|
||||
Point::ORIGIN,
|
||||
Point::new(
|
||||
2. * 680. * 210_f32.powi(2) / (680_f32.powi(2) + 210_f32.powi(2)),
|
||||
2. * 210. * 680_f32.powi(2) / (680_f32.powi(2) + 210_f32.powi(2)),
|
||||
),
|
||||
)
|
||||
.add_stop(0., self.accent.scale_alpha(0.20))
|
||||
.add_stop(1., self.base),
|
||||
);
|
||||
let wave = Path::new(|p| {
|
||||
p.move_to(Point::new(0., 180.));
|
||||
p.quadratic_curve_to(Point::new(160., 30.), Point::new(350., 145.));
|
||||
p.quadratic_curve_to(Point::new(540., 260.), Point::new(680., 100.));
|
||||
p.line_to(Point::new(680., 210.));
|
||||
p.line_to(Point::new(0., 210.));
|
||||
p.close();
|
||||
});
|
||||
frame.fill(&wave, self.accent.scale_alpha(0.055));
|
||||
frame.fill(
|
||||
&rounded(
|
||||
self.margin,
|
||||
self.bar_y,
|
||||
680. - 2. * self.margin,
|
||||
self.height,
|
||||
self.bar_radius,
|
||||
),
|
||||
self.base.scale_alpha(self.opacity),
|
||||
);
|
||||
frame.fill(
|
||||
&rounded(30., self.bar_y + self.height / 2. - 2.5, 12., 5., 2.5),
|
||||
self.accent,
|
||||
);
|
||||
frame.fill(
|
||||
&Path::circle(Point::new(51., self.bar_y + self.height / 2.), 2.5),
|
||||
self.muted,
|
||||
);
|
||||
let line = |frame: &mut Frame<cosmic::Renderer>, x, y, w, color, width| {
|
||||
frame.stroke(
|
||||
&Path::line(Point::new(x, y), Point::new(x + w, y)),
|
||||
Stroke::default()
|
||||
.with_color(color)
|
||||
.with_width(width * scale)
|
||||
.with_line_cap(LineCap::Round),
|
||||
)
|
||||
};
|
||||
for x in [595., 615., 635.] {
|
||||
line(frame, x, self.bar_y + self.height / 2., 10., self.muted, 3.);
|
||||
}
|
||||
let left_width = 288. - self.gap / 2.;
|
||||
let right = 340. + self.gap / 2.;
|
||||
let left = rounded(44., 60., left_width, 132., self.radius);
|
||||
frame.fill(&left, self.card);
|
||||
frame.stroke(
|
||||
&left,
|
||||
Stroke::default()
|
||||
.with_color(self.accent)
|
||||
.with_width(1.2 * scale),
|
||||
);
|
||||
frame.fill(
|
||||
&rounded(right, 60., left_width, 132., self.radius),
|
||||
self.card,
|
||||
);
|
||||
for (y, w) in [(80., 65.), (100., 170.), (115., 120.), (130., 152.)] {
|
||||
line(frame, 64., y, w, self.muted.scale_alpha(0.45), 4.);
|
||||
}
|
||||
frame.fill(&rounded(right + 16., 78., 50., 94., 5.), self.base);
|
||||
for (y, w) in [(82., 85.), (103., 175.), (121., 150.), (139., 160.)] {
|
||||
line(frame, 420., y, w, self.muted.scale_alpha(0.32), 4.);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn view(
|
||||
snapshot: &Snapshot,
|
||||
theme: &ferese_config::theme::ResolvedTheme,
|
||||
) -> Element<'static, Message> {
|
||||
cosmic::Element::new(Preview(Scene::new(snapshot, theme)))
|
||||
}
|
||||
struct Preview(Scene);
|
||||
struct State {
|
||||
scene: Scene,
|
||||
cache: Cache<cosmic::Renderer>,
|
||||
}
|
||||
impl Widget<Message, cosmic::Theme, cosmic::Renderer> for Preview {
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State {
|
||||
scene: self.0.clone(),
|
||||
cache: Cache::default(),
|
||||
})
|
||||
}
|
||||
fn diff(&mut self, tree: &mut widget::Tree) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
if state.scene != self.0 {
|
||||
state.scene = self.0.clone();
|
||||
state.cache.clear();
|
||||
}
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size::new(Length::Fill, Length::Fixed(84.))
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
_: &mut widget::Tree,
|
||||
_: &cosmic::Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout::Node::new(limits.resolve(Length::Fill, Length::Fixed(84.), Size::new(272., 84.)))
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut cosmic::Renderer,
|
||||
_: &cosmic::Theme,
|
||||
_: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let bounds = layout.bounds();
|
||||
if !bounds.intersects(viewport) {
|
||||
return;
|
||||
}
|
||||
let scale = (bounds.width / 680.).min(bounds.height / 210.);
|
||||
let size = Size::new(680. * scale, 210. * scale);
|
||||
let state = tree.state.downcast_ref::<State>();
|
||||
let geometry = state.cache.draw(renderer, size, |frame| {
|
||||
frame.scale(scale);
|
||||
self.0.paint(frame, scale);
|
||||
});
|
||||
renderer.with_layer(*viewport, |renderer| {
|
||||
renderer.with_translation(
|
||||
Vector::new(
|
||||
bounds.center_x() - size.width / 2.,
|
||||
bounds.center_y() - size.height / 2.,
|
||||
),
|
||||
|renderer| renderer.draw_geometry(geometry),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::iced::advanced::renderer::Headless;
|
||||
#[test]
|
||||
fn vector_scene_matches_the_existing_svg_illustration() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut renderer = runtime
|
||||
.block_on(<cosmic::Renderer as Headless>::new(
|
||||
cosmic::iced::Font::default(),
|
||||
cosmic::iced::Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.unwrap();
|
||||
let snapshot = Snapshot::parse("layout { inner-gap 24; }".into()).unwrap();
|
||||
let theme = ferese_config::theme::default_theme();
|
||||
let mut outputs = Vec::new();
|
||||
for mut view in [
|
||||
crate::visuals::legacy_preview_resolved(&snapshot, &theme),
|
||||
view(&snapshot, &theme),
|
||||
] {
|
||||
let mut tree = widget::Tree::new(&view);
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, Size::new(800., 84.)),
|
||||
);
|
||||
renderer.reset(node.bounds());
|
||||
view.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&cosmic::Theme::default(),
|
||||
&renderer::Style {
|
||||
text_color: Color::WHITE,
|
||||
icon_color: Color::WHITE,
|
||||
scale_factor: 2.,
|
||||
},
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&node.bounds(),
|
||||
);
|
||||
outputs.push(Headless::screenshot(
|
||||
&mut renderer,
|
||||
Size::new(1600, 168),
|
||||
2.,
|
||||
Color::TRANSPARENT,
|
||||
));
|
||||
}
|
||||
let error = outputs[0]
|
||||
.iter()
|
||||
.zip(&outputs[1])
|
||||
.map(|(a, b)| u64::from(a.abs_diff(*b)))
|
||||
.sum::<u64>() as f64
|
||||
/ outputs[0].len() as f64;
|
||||
if let Some(path) = std::env::var_os("FERESE_PREVIEW_COMPARISON") {
|
||||
let path = std::path::PathBuf::from(path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
for (i, pixels) in outputs.iter().enumerate() {
|
||||
image::save_buffer(
|
||||
path.join(format!("window-{i}.png")),
|
||||
pixels,
|
||||
1600,
|
||||
168,
|
||||
image::ColorType::Rgba8,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
eprintln!("SVG/native mean channel error {error:.3}");
|
||||
assert!(error < 0.3, "SVG/native mean channel error {error:.3}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user