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:
1 parent
629414e0fb
commit
647eaf4ad9
23 files changed
+1098
-220
No files matched your search
+284
-21
@@ -117,38 +117,225 @@ impl ConfigMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Ferese {
|
||||
pub(crate) fn reload_config_source(&mut self, source: String) -> Result<(), String> {
|
||||
#[derive(Clone)]
|
||||
struct Prepared {
|
||||
source: String,
|
||||
sections: serde_json::Value,
|
||||
runtime: crate::RuntimeConfig,
|
||||
candidate: ferese_config::theme::Candidate,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
fn new(source: String, directory: &std::path::Path) -> Result<Self, String> {
|
||||
Self::with_watch(source, directory, &mut Vec::new())
|
||||
}
|
||||
|
||||
fn with_watch(source: String, directory: &std::path::Path, watched: &mut Vec<PathBuf>) -> Result<Self, String> {
|
||||
if source.len() > 60 * 1024 {
|
||||
return Err("configuration exceeds the 60 KiB live-reload limit".into());
|
||||
}
|
||||
// Validate every runtime field before mutating any scene/input state.
|
||||
let directory = crate::config::config_path()
|
||||
.and_then(|path| path.parent().map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let (_, mut runtime, candidate) = match crate::theme::prepare(&source, &directory) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
self.theme_engine.reject(error.clone());
|
||||
return Err(error);
|
||||
|
||||
let document = ferese_config::Document::parse(&source).map_err(|e| e.to_string())?;
|
||||
if let Some(value) = document.get("theme")
|
||||
&& let Ok(policy) = serde_json::from_value::<ferese_config::theme::Policy>(value.clone())
|
||||
{
|
||||
watched.extend(
|
||||
[&policy.file, &policy.light.file, &policy.dark.file]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(policy.custom_themes.values().map(|theme| &theme.file))
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.map(|path| ferese_config::theme::theme_path(directory, path)),
|
||||
);
|
||||
}
|
||||
let (_, runtime, candidate) = crate::theme::prepare_document(&document, directory)?;
|
||||
let mut sections = document.value().clone();
|
||||
if let Some(object) = sections.as_object_mut() {
|
||||
object.remove("theme");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
source,
|
||||
sections,
|
||||
runtime,
|
||||
candidate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn file_stamps(files: &[PathBuf]) -> Vec<(PathBuf, Option<(std::time::SystemTime, u64)>)> {
|
||||
files
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let stamp = path
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok().map(|time| (time, m.len())));
|
||||
(path.clone(), stamp)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
type Reply = (u64, std::sync::mpsc::SyncSender<ferese_ipc::Response>);
|
||||
struct Request {
|
||||
force: bool,
|
||||
fallback: Option<String>,
|
||||
reply: Option<Reply>,
|
||||
}
|
||||
|
||||
pub(crate) struct Worker {
|
||||
requests: std::sync::mpsc::SyncSender<Request>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub(crate) fn new(
|
||||
event_loop: &mut calloop::EventLoop<'static, crate::Ferese>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let (requests, receiver) = std::sync::mpsc::sync_channel::<Request>(8);
|
||||
let (results, events) = calloop::channel::channel::<(Result<Prepared, String>, Vec<PathBuf>, Option<Reply>)>();
|
||||
event_loop.handle().insert_source(events, |event, _, state| {
|
||||
if let calloop::channel::Event::Msg((result, watched, reply)) = event {
|
||||
state.theme_engine.watch_files(&watched);
|
||||
let result = result.and_then(|prepared| state.apply_prepared_config(prepared));
|
||||
if let Err(error) = &result {
|
||||
state.theme_engine.reject(error.clone());
|
||||
}
|
||||
|
||||
if let Some((id, reply)) = reply {
|
||||
let response = match result {
|
||||
Ok(()) => ferese_ipc::Response::success(id, serde_json::json!({})),
|
||||
Err(error) => ferese_ipc::Response::error(id, "invalid_config", error),
|
||||
};
|
||||
let _ = reply.try_send(response);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
std::thread::Builder::new()
|
||||
.name("ferese-config".into())
|
||||
.spawn(move || {
|
||||
let mut cached: Option<Prepared> = None;
|
||||
let mut stamps = Vec::new();
|
||||
for request in receiver {
|
||||
let started = std::time::Instant::now();
|
||||
let path = crate::config::config_path();
|
||||
let directory = path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent())
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let mut watched = Vec::new();
|
||||
let result = path
|
||||
.as_ref()
|
||||
.map_or_else(
|
||||
|| request.fallback.ok_or_else(|| "No configuration available".into()),
|
||||
|path| {
|
||||
if path.exists() || !request.force {
|
||||
crate::theme::read_source(path)
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
},
|
||||
)
|
||||
.and_then(|source| {
|
||||
// Watcher and explicit reloads share the same cache.
|
||||
// Auto mode still resolves time and system appearance.
|
||||
if let Some(previous) = &cached
|
||||
&& previous.source == source
|
||||
&& previous.candidate.policy.mode != ferese_config::theme::Mode::Auto
|
||||
&& file_stamps(&previous.candidate.files) == stamps
|
||||
{
|
||||
return Ok(previous.clone());
|
||||
}
|
||||
|
||||
let prepared = Prepared::with_watch(source, directory, &mut watched)?;
|
||||
stamps = file_stamps(&prepared.candidate.files);
|
||||
cached = Some(prepared.clone());
|
||||
Ok(prepared)
|
||||
});
|
||||
tracing::debug!(
|
||||
elapsed_us = started.elapsed().as_micros() as u64,
|
||||
"configuration prepared on worker"
|
||||
);
|
||||
if results.send((result, watched, request.reply)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self { requests })
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Ferese {
|
||||
pub(crate) fn queue_config_reload(&self, force: bool, reply: Option<Reply>) -> Result<(), String> {
|
||||
let request = Request {
|
||||
force,
|
||||
fallback: self.config_source.clone(),
|
||||
reply,
|
||||
};
|
||||
let worker = self.config_worker.as_ref().ok_or("configuration worker unavailable")?;
|
||||
if let Err(error) = worker.requests.try_send(request) {
|
||||
let (std::sync::mpsc::TrySendError::Full(request) | std::sync::mpsc::TrySendError::Disconnected(request)) =
|
||||
error;
|
||||
if let Some((id, reply)) = request.reply {
|
||||
let _ = reply.try_send(ferese_ipc::Response::error(
|
||||
id,
|
||||
"reload_busy",
|
||||
"Configuration reload queue is full",
|
||||
));
|
||||
}
|
||||
return Err("Configuration reload queue is full".into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_prepared_config(&mut self, prepared: Prepared) -> Result<(), String> {
|
||||
let Prepared {
|
||||
source,
|
||||
sections,
|
||||
mut runtime,
|
||||
candidate,
|
||||
} = prepared;
|
||||
// Identical watcher/IPC reloads do not restart a theme transition.
|
||||
if self.config_source.as_ref() == Some(&source)
|
||||
&& candidate.theme == self.theme_engine.snapshot.theme
|
||||
&& candidate.families == self.theme_engine.snapshot.families
|
||||
&& candidate.policy.mode == self.theme_engine.snapshot.mode
|
||||
&& candidate.warnings == self.theme_engine.snapshot.warnings
|
||||
&& candidate.fallback_note == self.theme_engine.snapshot.fallback_note
|
||||
&& self.theme_engine.matches_files(&candidate.files)
|
||||
&& self.theme_engine.snapshot.error.is_none()
|
||||
{
|
||||
if let Err(error) = self.theme_engine.arm_clock(candidate.next_transition) {
|
||||
tracing::warn!(%error, "cannot arm appearance schedule");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let wallpaper = runtime.wallpaper.clone();
|
||||
runtime.wallpaper = self.wallpaper.configuration().clone();
|
||||
let previous_theme = self.theme_settings;
|
||||
let non_theme = |source: &str| -> Option<serde_json::Value> {
|
||||
let document = ferese_config::Document::parse(source).ok()?;
|
||||
let mut value = document.value().clone();
|
||||
value.as_object_mut()?.remove("theme");
|
||||
Some(value)
|
||||
};
|
||||
if self.config_source.as_deref().and_then(non_theme) != non_theme(&source) {
|
||||
self.apply_runtime_config(runtime)?;
|
||||
if self.config_sections.is_none() {
|
||||
self.config_sections = self
|
||||
.config_source
|
||||
.as_deref()
|
||||
.and_then(|s| ferese_config::Document::parse(s).ok())
|
||||
.map(|document| {
|
||||
let mut value = document.value().clone();
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.remove("theme");
|
||||
}
|
||||
value
|
||||
});
|
||||
}
|
||||
if self.config_sections.as_ref() != Some(§ions) {
|
||||
self.apply_runtime_config(runtime, §ions)?;
|
||||
}
|
||||
|
||||
self.overview
|
||||
.set_font_family(candidate.theme.tokens.typography.font_family.clone());
|
||||
self.theme_settings = previous_theme;
|
||||
self.accept_theme(candidate, wallpaper);
|
||||
self.config_sections = Some(sections);
|
||||
self.config_source = Some(source.clone());
|
||||
self.shell_resources.retain(|resource| {
|
||||
if let Ok(shell) = resource.upgrade() {
|
||||
@@ -164,10 +351,17 @@ impl crate::Ferese {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn reload_config_source(&mut self, source: String) -> Result<(), String> {
|
||||
let directory = crate::config::config_path()
|
||||
.and_then(|p| p.parent().map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let prepared = Prepared::new(source, &directory)?;
|
||||
self.apply_prepared_config(prepared)
|
||||
}
|
||||
|
||||
pub(crate) fn reload_config(&mut self) -> Result<(), String> {
|
||||
let path = crate::config::config_path().ok_or("config directory is unavailable")?;
|
||||
let source = crate::theme::read_source(&path)?;
|
||||
self.reload_config_source(source)
|
||||
self.reload_config_source(crate::theme::read_source(&path)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +388,75 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_reload_applies_once_and_rejects_invalid_configuration() {
|
||||
const CHILD: &str = "FERESE_WORKER_RELOAD_TEST_CHILD";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = Directory::new();
|
||||
std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"reload::tests::worker_reload_applies_once_and_rejects_invalid_configuration",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.env("XDG_RUNTIME_DIR", &directory.0)
|
||||
.env("XDG_CONFIG_HOME", &directory.0)
|
||||
.env_remove("FERESE_SOCKET")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let (_, runtime, _) = crate::theme::prepare("", std::path::Path::new(".")).unwrap();
|
||||
let mut event_loop = calloop::EventLoop::try_new().unwrap();
|
||||
let display = smithay::reexports::wayland_server::Display::new().unwrap();
|
||||
let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap();
|
||||
let path = crate::config::config_path().unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let valid = "animations { speed 0.5; }";
|
||||
std::fs::write(&path, valid).unwrap();
|
||||
for (index, source) in [Some(valid), Some(valid), Some("animations { speed -1.0; }"), None]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if let Some(source) = source {
|
||||
std::fs::write(&path, source).unwrap();
|
||||
} else {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
let before = state.theme_engine.snapshot.revision;
|
||||
let (reply, received) = std::sync::mpsc::sync_channel(1);
|
||||
state.queue_config_reload(index == 1, Some((42, reply))).unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let response = loop {
|
||||
event_loop.dispatch(Duration::from_millis(10), &mut state).unwrap();
|
||||
if let Ok(response) = received.try_recv() {
|
||||
break response;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "worker did not reply");
|
||||
};
|
||||
assert_eq!(response.id, 42);
|
||||
assert_eq!(response.error.is_some(), source != Some(valid));
|
||||
assert_eq!(
|
||||
state.animation_duration(Duration::from_millis(100)),
|
||||
Duration::from_millis(200)
|
||||
);
|
||||
assert_eq!(state.config_source.as_deref(), Some(valid));
|
||||
if source == Some(valid) && before > 0 {
|
||||
assert_eq!(state.theme_engine.snapshot.revision, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_reload_keeps_dimming_policy_independent_of_theme_transitions() {
|
||||
if std::env::var_os("FERESE_DIM_RELOAD_TEST_CHILD").is_none() {
|
||||
|
||||
Reference in new issue
Block a user