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
+1022
-144
No files matched your search
@@ -25,7 +25,8 @@ fn visible(states: &RenderElementStates) -> HashSet<Id> {
|
|||||||
states
|
states
|
||||||
.states
|
.states
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(id, state)| is_visible(*state).then(|| id.clone()))
|
.filter(|&(_id, state)| is_visible(*state))
|
||||||
|
.map(|(id, _state)| id.clone())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,20 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
|||||||
"Missing session revision",
|
"Missing session revision",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
} else if call.request.command == "reload-config" {
|
||||||
|
if let Err(error) = validate_request(&call.request) {
|
||||||
|
let _ = call
|
||||||
|
.response
|
||||||
|
.try_send(Response::error(call.request.id, error.code, error.message));
|
||||||
|
} else if state.session_lock.active() {
|
||||||
|
let _ = call.response.try_send(Response::error(
|
||||||
|
call.request.id,
|
||||||
|
"session_locked",
|
||||||
|
"IPC unavailable while session is locked",
|
||||||
|
));
|
||||||
|
} else if let Err(error) = state.queue_config_reload(false, Some((call.request.id, call.response))) {
|
||||||
|
tracing::warn!(%error, "cannot queue configuration reload");
|
||||||
|
}
|
||||||
} else if call.request.command == "screenshot-window" {
|
} else if call.request.command == "screenshot-window" {
|
||||||
state.start_window_screenshot(call.request, call.response);
|
state.start_window_screenshot(call.request, call.response);
|
||||||
} else if call.request.command == "screenshot" {
|
} else if call.request.command == "screenshot" {
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
let display = Display::new()?;
|
let display = Display::new()?;
|
||||||
let mut state = Ferese::new(&mut event_loop, display, runtime)?;
|
let mut state = Ferese::new(&mut event_loop, display, runtime)?;
|
||||||
state.config_source = initial_source.filter(|source| source.len() <= 60 * 1024);
|
state.config_source = initial_source.filter(|source| source.len() <= 60 * 1024);
|
||||||
|
state.config_sections = state
|
||||||
|
.config_source
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|source| ferese_config::Document::parse(source).ok())
|
||||||
|
.map(|document| {
|
||||||
|
let mut value = document.value().clone();
|
||||||
|
if let Some(object) = value.as_object_mut() {
|
||||||
|
object.remove("theme");
|
||||||
|
}
|
||||||
|
value
|
||||||
|
});
|
||||||
theme::init(&mut event_loop, &mut state, candidate)?;
|
theme::init(&mut event_loop, &mut state, candidate)?;
|
||||||
resume::init(&mut event_loop)?;
|
resume::init(&mut event_loop)?;
|
||||||
overview::init_font_loader(&mut event_loop, &mut state)?;
|
overview::init_font_loader(&mut event_loop, &mut state)?;
|
||||||
|
|||||||
@@ -758,13 +758,10 @@ impl Ferese {
|
|||||||
if !self.overview.is_active() {
|
if !self.overview.is_active() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.space
|
let output = self.space.output_under(point).next()?;
|
||||||
.outputs()
|
|
||||||
.find(|output| {
|
|
||||||
self.output_bounds_for(output)
|
self.output_bounds_for(output)
|
||||||
.is_some_and(|bounds| contains(workspace_strip(bounds), point))
|
.is_some_and(|bounds| contains(workspace_strip(bounds), point))
|
||||||
})
|
.then(|| output.clone())
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn click_overview_workspace(&mut self, point: Point<f64, Logical>) -> bool {
|
pub(crate) fn click_overview_workspace(&mut self, point: Point<f64, Logical>) -> bool {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ impl PortalSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_end(&self, token: u32, inhibitor_revision: u32, force: bool) -> Result<(), String> {
|
pub(crate) fn validate_end(&self, token: u32, inhibitor_revision: u32, force: bool) -> Result<(), String> {
|
||||||
if !self.query_ready() || !self.query.as_ref().is_some_and(|query| query.token == token) {
|
if !self.query_ready() || self.query.as_ref().is_none_or(|query| query.token != token) {
|
||||||
return Err("Session-ending confirmation expired or applications are still responding".into());
|
return Err("Session-ending confirmation expired or applications are still responding".into());
|
||||||
}
|
}
|
||||||
if self.inhibitor_revision != inhibitor_revision
|
if self.inhibitor_revision != inhibitor_revision
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ impl Ferese {
|
|||||||
|
|
||||||
surfaces
|
surfaces
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|surface| mapped_tree(surface))
|
.filter(mapped_tree)
|
||||||
.filter_map(|surface| {
|
.filter_map(|surface| {
|
||||||
let id = Id::from(&surface);
|
let id = Id::from(&surface);
|
||||||
let output = self
|
let output = self
|
||||||
|
|||||||
+283
-20
@@ -117,38 +117,225 @@ impl ConfigMonitor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::Ferese {
|
#[derive(Clone)]
|
||||||
pub(crate) fn reload_config_source(&mut self, source: String) -> Result<(), String> {
|
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 {
|
if source.len() > 60 * 1024 {
|
||||||
return Err("configuration exceeds the 60 KiB live-reload limit".into());
|
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()
|
let document = ferese_config::Document::parse(&source).map_err(|e| e.to_string())?;
|
||||||
.and_then(|path| path.parent().map(ToOwned::to_owned))
|
if let Some(value) = document.get("theme")
|
||||||
.unwrap_or_else(|| PathBuf::from("."));
|
&& let Ok(policy) = serde_json::from_value::<ferese_config::theme::Policy>(value.clone())
|
||||||
let (_, mut runtime, candidate) = match crate::theme::prepare(&source, &directory) {
|
{
|
||||||
Ok(prepared) => prepared,
|
watched.extend(
|
||||||
Err(error) => {
|
[&policy.file, &policy.light.file, &policy.dark.file]
|
||||||
self.theme_engine.reject(error.clone());
|
.into_iter()
|
||||||
return Err(error);
|
.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();
|
let wallpaper = runtime.wallpaper.clone();
|
||||||
runtime.wallpaper = self.wallpaper.configuration().clone();
|
runtime.wallpaper = self.wallpaper.configuration().clone();
|
||||||
let previous_theme = self.theme_settings;
|
let previous_theme = self.theme_settings;
|
||||||
let non_theme = |source: &str| -> Option<serde_json::Value> {
|
if self.config_sections.is_none() {
|
||||||
let document = ferese_config::Document::parse(source).ok()?;
|
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();
|
let mut value = document.value().clone();
|
||||||
value.as_object_mut()?.remove("theme");
|
if let Some(object) = value.as_object_mut() {
|
||||||
Some(value)
|
object.remove("theme");
|
||||||
};
|
|
||||||
if self.config_source.as_deref().and_then(non_theme) != non_theme(&source) {
|
|
||||||
self.apply_runtime_config(runtime)?;
|
|
||||||
}
|
}
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.config_sections.as_ref() != Some(§ions) {
|
||||||
|
self.apply_runtime_config(runtime, §ions)?;
|
||||||
|
}
|
||||||
|
|
||||||
self.overview
|
self.overview
|
||||||
.set_font_family(candidate.theme.tokens.typography.font_family.clone());
|
.set_font_family(candidate.theme.tokens.typography.font_family.clone());
|
||||||
self.theme_settings = previous_theme;
|
self.theme_settings = previous_theme;
|
||||||
self.accept_theme(candidate, wallpaper);
|
self.accept_theme(candidate, wallpaper);
|
||||||
|
self.config_sections = Some(sections);
|
||||||
self.config_source = Some(source.clone());
|
self.config_source = Some(source.clone());
|
||||||
self.shell_resources.retain(|resource| {
|
self.shell_resources.retain(|resource| {
|
||||||
if let Ok(shell) = resource.upgrade() {
|
if let Ok(shell) = resource.upgrade() {
|
||||||
@@ -164,10 +351,17 @@ impl crate::Ferese {
|
|||||||
Ok(())
|
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> {
|
pub(crate) fn reload_config(&mut self) -> Result<(), String> {
|
||||||
let path = crate::config::config_path().ok_or("config directory is unavailable")?;
|
let path = crate::config::config_path().ok_or("config directory is unavailable")?;
|
||||||
let source = crate::theme::read_source(&path)?;
|
self.reload_config_source(crate::theme::read_source(&path)?)
|
||||||
self.reload_config_source(source)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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]
|
#[test]
|
||||||
fn live_reload_keeps_dimming_policy_independent_of_theme_transitions() {
|
fn live_reload_keeps_dimming_policy_independent_of_theme_transitions() {
|
||||||
if std::env::var_os("FERESE_DIM_RELOAD_TEST_CHILD").is_none() {
|
if std::env::var_os("FERESE_DIM_RELOAD_TEST_CHILD").is_none() {
|
||||||
|
|||||||
+39
-3
@@ -317,6 +317,8 @@ pub struct Ferese {
|
|||||||
pub(crate) session_lock: Lock,
|
pub(crate) session_lock: Lock,
|
||||||
pub(crate) lock_idle: IdleSettings,
|
pub(crate) lock_idle: IdleSettings,
|
||||||
pub(crate) config_source: Option<String>,
|
pub(crate) config_source: Option<String>,
|
||||||
|
pub(crate) config_sections: Option<serde_json::Value>,
|
||||||
|
pub(crate) config_worker: Option<crate::reload::Worker>,
|
||||||
pub(crate) theme_engine: crate::theme::Engine,
|
pub(crate) theme_engine: crate::theme::Engine,
|
||||||
pub start_time: Instant,
|
pub start_time: Instant,
|
||||||
pub socket_name: OsString,
|
pub socket_name: OsString,
|
||||||
@@ -434,6 +436,7 @@ pub struct Ferese {
|
|||||||
pub xdg_toplevel_icon_manager: XdgToplevelIconManager,
|
pub xdg_toplevel_icon_manager: XdgToplevelIconManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct RuntimeConfig {
|
pub struct RuntimeConfig {
|
||||||
pub(crate) lock_idle: IdleSettings,
|
pub(crate) lock_idle: IdleSettings,
|
||||||
pub(crate) autostart: Vec<DaemonConfig>,
|
pub(crate) autostart: Vec<DaemonConfig>,
|
||||||
@@ -542,6 +545,8 @@ impl Ferese {
|
|||||||
session_lock: crate::session_lock::Lock::default(),
|
session_lock: crate::session_lock::Lock::default(),
|
||||||
lock_idle: config.lock_idle,
|
lock_idle: config.lock_idle,
|
||||||
config_source: None,
|
config_source: None,
|
||||||
|
config_sections: None,
|
||||||
|
config_worker: None,
|
||||||
theme_engine: Default::default(),
|
theme_engine: Default::default(),
|
||||||
start_time,
|
start_time,
|
||||||
socket_name,
|
socket_name,
|
||||||
@@ -659,6 +664,7 @@ impl Ferese {
|
|||||||
xdg_shell_state,
|
xdg_shell_state,
|
||||||
xdg_toplevel_icon_manager,
|
xdg_toplevel_icon_manager,
|
||||||
};
|
};
|
||||||
|
state.config_worker = Some(crate::reload::Worker::new(event_loop)?);
|
||||||
let screenshot = crate::ipc::init(event_loop)?;
|
let screenshot = crate::ipc::init(event_loop)?;
|
||||||
state.screenshot_parts = Some(screenshot.parts);
|
state.screenshot_parts = Some(screenshot.parts);
|
||||||
state.screenshot_worker = Some(screenshot.worker);
|
state.screenshot_worker = Some(screenshot.worker);
|
||||||
@@ -667,7 +673,23 @@ impl Ferese {
|
|||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn apply_runtime_config(&mut self, config: RuntimeConfig) -> Result<(), String> {
|
pub(crate) fn apply_runtime_config(
|
||||||
|
&mut self,
|
||||||
|
config: RuntimeConfig,
|
||||||
|
sections: &serde_json::Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let changed = |keys: &[&str]| {
|
||||||
|
self.config_sections
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|previous| keys.iter().any(|key| previous.get(*key) != sections.get(*key)))
|
||||||
|
};
|
||||||
|
let layout_changed = changed(&["layout", "scrolling", "workspaces"]);
|
||||||
|
let bindings_changed = changed(&["bindings", "commands"]);
|
||||||
|
let input_changed = changed(&["input"]);
|
||||||
|
let daemons_changed = changed(&["autostart"]);
|
||||||
|
let lock_changed = changed(&["lock_screen"]);
|
||||||
|
let scene_changed = layout_changed || changed(&["animations", "appearance", "window_rules", "output_profiles"]);
|
||||||
|
|
||||||
if self.output_profiles != config.output_profiles {
|
if self.output_profiles != config.output_profiles {
|
||||||
crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?;
|
crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?;
|
||||||
}
|
}
|
||||||
@@ -676,7 +698,7 @@ impl Ferese {
|
|||||||
|| self.input_settings.xkb_variant != config.input_settings.xkb_variant
|
|| self.input_settings.xkb_variant != config.input_settings.xkb_variant
|
||||||
|| self.input_settings.xkb_options != config.input_settings.xkb_options;
|
|| self.input_settings.xkb_options != config.input_settings.xkb_options;
|
||||||
|
|
||||||
if let Some(keyboard) = self.seat.get_keyboard() {
|
if input_changed && let Some(keyboard) = self.seat.get_keyboard() {
|
||||||
if keyboard_changed {
|
if keyboard_changed {
|
||||||
self.input_capture.close_all();
|
self.input_capture.close_all();
|
||||||
if self.input_capture.restore_focus {
|
if self.input_capture.restore_focus {
|
||||||
@@ -700,12 +722,17 @@ impl Ferese {
|
|||||||
keyboard.change_repeat_info(config.input_settings.repeat_rate, config.input_settings.repeat_delay_ms);
|
keyboard.change_repeat_info(config.input_settings.repeat_rate, config.input_settings.repeat_delay_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if lock_changed {
|
||||||
self.lock_idle = config.lock_idle;
|
self.lock_idle = config.lock_idle;
|
||||||
self.refresh_lock_idle_policy();
|
self.refresh_lock_idle_policy();
|
||||||
|
}
|
||||||
|
if scene_changed {
|
||||||
self.advance_animations(Instant::now());
|
self.advance_animations(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad;
|
let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad;
|
||||||
let outputs_changed = self.output_profiles != config.output_profiles;
|
let outputs_changed = self.output_profiles != config.output_profiles;
|
||||||
|
if layout_changed {
|
||||||
let bounds = self
|
let bounds = self
|
||||||
.workspaces
|
.workspaces
|
||||||
.iter()
|
.iter()
|
||||||
@@ -730,11 +757,16 @@ impl Ferese {
|
|||||||
&bounds,
|
&bounds,
|
||||||
)
|
)
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
}
|
||||||
self.gap_config = config.gap_config;
|
self.gap_config = config.gap_config;
|
||||||
self.input_settings = config.input_settings;
|
self.input_settings = config.input_settings;
|
||||||
|
if bindings_changed {
|
||||||
self.bindings = config.bindings.into();
|
self.bindings = config.bindings.into();
|
||||||
|
}
|
||||||
self.workspace_auto_back_and_forth = config.workspace_auto_back_and_forth;
|
self.workspace_auto_back_and_forth = config.workspace_auto_back_and_forth;
|
||||||
|
if bindings_changed || input_changed {
|
||||||
self.portal_shortcuts.reconcile(&self.bindings, &self.input_settings);
|
self.portal_shortcuts.reconcile(&self.bindings, &self.input_settings);
|
||||||
|
}
|
||||||
let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules);
|
let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules);
|
||||||
self.theme_settings = config.theme_settings;
|
self.theme_settings = config.theme_settings;
|
||||||
self.inactive_dim = config.inactive_dim;
|
self.inactive_dim = config.inactive_dim;
|
||||||
@@ -749,7 +781,7 @@ impl Ferese {
|
|||||||
self.viewport_spring_config = config.viewport_spring_config;
|
self.viewport_spring_config = config.viewport_spring_config;
|
||||||
self.output_profiles = config.output_profiles;
|
self.output_profiles = config.output_profiles;
|
||||||
self.autostart = config.autostart;
|
self.autostart = config.autostart;
|
||||||
if let Some(runner) = self.daemons.clone() {
|
if daemons_changed && let Some(runner) = self.daemons.clone() {
|
||||||
crate::daemon::Runner::refresh(&runner, self);
|
crate::daemon::Runner::refresh(&runner, self);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,10 +798,14 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.overview.set_font_family(config.overview_font_family);
|
self.overview.set_font_family(config.overview_font_family);
|
||||||
|
if self.wallpaper.configuration() != &config.wallpaper {
|
||||||
self.wallpaper.reload(config.wallpaper);
|
self.wallpaper.reload(config.wallpaper);
|
||||||
|
}
|
||||||
|
|
||||||
|
if scene_changed {
|
||||||
self.relayout();
|
self.relayout();
|
||||||
crate::backends::direct::render_all(self);
|
crate::backends::direct::render_all(self);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,10 +309,10 @@ impl Ferese {
|
|||||||
// Keep scheduling frames while waiting, so the deadline cannot stall.
|
// Keep scheduling frames while waiting, so the deadline cannot stall.
|
||||||
active_animation |= !blocked_workspaces.is_empty() || !resume_pauses.is_empty();
|
active_animation |= !blocked_workspaces.is_empty() || !resume_pauses.is_empty();
|
||||||
for (workspace, viewport) in &mut self.viewport_animations {
|
for (workspace, viewport) in &mut self.viewport_animations {
|
||||||
if !self
|
if self
|
||||||
.output_workspaces
|
.output_workspaces
|
||||||
.output_for_workspace(*workspace)
|
.output_for_workspace(*workspace)
|
||||||
.is_some_and(|output| self.output_workspaces.active_workspace(output) == Some(*workspace))
|
.is_none_or(|output| self.output_workspaces.active_workspace(output) != Some(*workspace))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ impl Ferese {
|
|||||||
&& self.window_content_ready(window)
|
&& self.window_content_ready(window)
|
||||||
&& self.windows.geometry(id).is_some()
|
&& self.windows.geometry(id).is_some()
|
||||||
&& self.output_workspaces.focused_output().is_some()
|
&& self.output_workspaces.focused_output().is_some()
|
||||||
&& !self.windows.record(*id).is_some_and(|record| record.closing.is_some()))
|
&& self.windows.record(*id).is_none_or(|record| record.closing.is_none()))
|
||||||
.then_some(*id)
|
.then_some(*id)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ impl Ferese {
|
|||||||
.map(|(_, surface, origin)| (surface, origin))
|
.map(|(_, surface, origin)| (surface, origin))
|
||||||
.or_else(|| self.window_surface_under(position))
|
.or_else(|| self.window_surface_under(position))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
|
if self.overview.is_active() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
self.layer_surface_under(position, &[Layer::Bottom, Layer::Background])
|
self.layer_surface_under(position, &[Layer::Bottom, Layer::Background])
|
||||||
.map(|(_, surface, origin)| (surface, origin))
|
.map(|(_, surface, origin)| (surface, origin))
|
||||||
})
|
})
|
||||||
@@ -32,7 +36,7 @@ impl Ferese {
|
|||||||
if let Some(layer) = self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) {
|
if let Some(layer) = self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) {
|
||||||
return Some(layer);
|
return Some(layer);
|
||||||
}
|
}
|
||||||
if self.window_surface_under(position).is_some() {
|
if self.overview.is_active() || self.window_surface_under(position).is_some() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,15 +235,7 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn focus_output_at(&mut self, position: Point<f64, Logical>) {
|
pub(crate) fn focus_output_at(&mut self, position: Point<f64, Logical>) {
|
||||||
let output = self.space.outputs().find_map(|output| {
|
let Some(output) = self.space.output_under(position).next().cloned() else {
|
||||||
let geometry = self.space.output_geometry(output)?;
|
|
||||||
(position.x >= f64::from(geometry.loc.x)
|
|
||||||
&& position.y >= f64::from(geometry.loc.y)
|
|
||||||
&& position.x < f64::from(geometry.loc.x + geometry.size.w)
|
|
||||||
&& position.y < f64::from(geometry.loc.y + geometry.size.h))
|
|
||||||
.then(|| output.clone())
|
|
||||||
});
|
|
||||||
let Some(output) = output else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(output_id) = self.output_ids.get(&output).copied() else {
|
let Some(output_id) = self.output_ids.get(&output).copied() else {
|
||||||
@@ -260,3 +252,182 @@ impl Ferese {
|
|||||||
self.relayout();
|
self.relayout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use smithay::backend::input::{ButtonState, Event, InputBackend, InputEvent, PointerButtonEvent, UnusedEvent};
|
||||||
|
use smithay::backend::winit::WinitVirtualDevice;
|
||||||
|
use smithay::input::pointer::MotionEvent;
|
||||||
|
use smithay::utils::SERIAL_COUNTER;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TestInput;
|
||||||
|
|
||||||
|
impl InputBackend for TestInput {
|
||||||
|
type Device = WinitVirtualDevice;
|
||||||
|
type PointerButtonEvent = TestButton;
|
||||||
|
type KeyboardKeyEvent = UnusedEvent;
|
||||||
|
type PointerAxisEvent = UnusedEvent;
|
||||||
|
type PointerMotionEvent = UnusedEvent;
|
||||||
|
type PointerMotionAbsoluteEvent = UnusedEvent;
|
||||||
|
type GestureSwipeBeginEvent = UnusedEvent;
|
||||||
|
type GestureSwipeUpdateEvent = UnusedEvent;
|
||||||
|
type GestureSwipeEndEvent = UnusedEvent;
|
||||||
|
type GesturePinchBeginEvent = UnusedEvent;
|
||||||
|
type GesturePinchUpdateEvent = UnusedEvent;
|
||||||
|
type GesturePinchEndEvent = UnusedEvent;
|
||||||
|
type GestureHoldBeginEvent = UnusedEvent;
|
||||||
|
type GestureHoldEndEvent = UnusedEvent;
|
||||||
|
type TouchDownEvent = UnusedEvent;
|
||||||
|
type TouchUpEvent = UnusedEvent;
|
||||||
|
type TouchMotionEvent = UnusedEvent;
|
||||||
|
type TouchCancelEvent = UnusedEvent;
|
||||||
|
type TouchFrameEvent = UnusedEvent;
|
||||||
|
type TabletToolAxisEvent = UnusedEvent;
|
||||||
|
type TabletToolProximityEvent = UnusedEvent;
|
||||||
|
type TabletToolTipEvent = UnusedEvent;
|
||||||
|
type TabletToolButtonEvent = UnusedEvent;
|
||||||
|
type SwitchToggleEvent = UnusedEvent;
|
||||||
|
type SpecialEvent = UnusedEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TestButton(ButtonState);
|
||||||
|
|
||||||
|
impl Event<TestInput> for TestButton {
|
||||||
|
fn time(&self) -> u64 {
|
||||||
|
1_000
|
||||||
|
}
|
||||||
|
fn device(&self) -> WinitVirtualDevice {
|
||||||
|
WinitVirtualDevice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PointerButtonEvent<TestInput> for TestButton {
|
||||||
|
fn button_code(&self) -> u32 {
|
||||||
|
0x110
|
||||||
|
}
|
||||||
|
fn state(&self) -> ButtonState {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn click(state: &mut Ferese, position: Point<f64, Logical>) {
|
||||||
|
let pointer = state.seat.get_pointer().unwrap();
|
||||||
|
pointer.motion(
|
||||||
|
state,
|
||||||
|
state.surface_under(position),
|
||||||
|
&MotionEvent {
|
||||||
|
location: position,
|
||||||
|
serial: SERIAL_COUNTER.next_serial(),
|
||||||
|
time: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
pointer.frame(state);
|
||||||
|
|
||||||
|
for button in [ButtonState::Pressed, ButtonState::Released] {
|
||||||
|
state.process_input_event::<TestInput>(InputEvent::PointerButton {
|
||||||
|
event: TestButton(button),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"]
|
||||||
|
fn overlapping_outputs_agree_on_pointer_focus_and_overview_selection() {
|
||||||
|
let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap());
|
||||||
|
assert!(runtime.starts_with(std::env::temp_dir()));
|
||||||
|
|
||||||
|
let mut event_loop = EventLoop::try_new().unwrap();
|
||||||
|
let config = crate::config::Config::default().runtime_config().unwrap();
|
||||||
|
let mut state = Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap();
|
||||||
|
let mut outputs = Vec::new();
|
||||||
|
|
||||||
|
for name in ["first", "second"] {
|
||||||
|
let output = Output::new(
|
||||||
|
name.into(),
|
||||||
|
smithay::output::PhysicalProperties {
|
||||||
|
size: (0, 0).into(),
|
||||||
|
subpixel: smithay::output::Subpixel::Unknown,
|
||||||
|
make: "test".into(),
|
||||||
|
model: "test".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
output.change_current_state(
|
||||||
|
Some(smithay::output::Mode {
|
||||||
|
size: (1920, 1080).into(),
|
||||||
|
refresh: 60_000,
|
||||||
|
}),
|
||||||
|
Some(smithay::utils::Transform::Normal),
|
||||||
|
Some(smithay::output::Scale::Fractional(1.75)),
|
||||||
|
Some((0, 0).into()),
|
||||||
|
);
|
||||||
|
state.space.map_output(&output, (0, 0));
|
||||||
|
state.register_output(&output, name.into());
|
||||||
|
outputs.push(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
let position = Point::from((300.0, 300.0));
|
||||||
|
let expected_output = state.space.output_under(position).next().unwrap().clone();
|
||||||
|
assert_eq!(expected_output, outputs[1]);
|
||||||
|
let expected_workspace = state.workspace_under_pointer(position).unwrap();
|
||||||
|
|
||||||
|
state.focus_output_at(position);
|
||||||
|
assert_eq!(state.workspaces.active_id(), expected_workspace);
|
||||||
|
assert_eq!(
|
||||||
|
state.output_workspaces.focused_output(),
|
||||||
|
state.output_id(&expected_output)
|
||||||
|
);
|
||||||
|
|
||||||
|
state
|
||||||
|
.workspaces
|
||||||
|
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
state.relayout();
|
||||||
|
state.set_overview_active(true);
|
||||||
|
let card = state
|
||||||
|
.overview_workspace_cards(&expected_output)
|
||||||
|
.into_iter()
|
||||||
|
.find(|card| card.workspace != expected_workspace)
|
||||||
|
.expect("occupied workspace has an empty successor");
|
||||||
|
let position = Point::from((
|
||||||
|
card.rect.x + card.rect.width / 2.0,
|
||||||
|
card.rect.y + card.rect.height / 2.0,
|
||||||
|
));
|
||||||
|
assert_eq!(state.overview_strip_at(position), Some(expected_output.clone()));
|
||||||
|
click(&mut state, position);
|
||||||
|
assert_eq!(state.workspaces.active_id(), card.workspace);
|
||||||
|
state.windows.records.insert(
|
||||||
|
WindowId(1),
|
||||||
|
super::super::window_registry::WindowRecord {
|
||||||
|
geometry: Some(WindowGeometry::new(Rect::new(0.0, 0.0, 800.0, 600.0), None)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let occupied = state
|
||||||
|
.overview_workspace_cards(&expected_output)
|
||||||
|
.into_iter()
|
||||||
|
.find(|card| card.workspace == expected_workspace)
|
||||||
|
.unwrap();
|
||||||
|
let preview = occupied.windows[0].1;
|
||||||
|
let image_point = Point::from((preview.x + preview.width / 2.0, preview.y + preview.height / 2.0));
|
||||||
|
let label_point = Point::from((
|
||||||
|
occupied.rect.x + occupied.rect.width / 2.0,
|
||||||
|
occupied.rect.y + occupied.rect.height - 8.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
for position in [image_point, label_point] {
|
||||||
|
state.activate_managed_workspace(card.workspace);
|
||||||
|
click(&mut state, position);
|
||||||
|
assert_eq!(state.workspaces.active_id(), expected_workspace);
|
||||||
|
assert!(state.overview.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state.output_workspaces.focused_output(),
|
||||||
|
state.output_id(&expected_output)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -664,7 +664,7 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool {
|
pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool {
|
||||||
if !self.windows.window(id).is_some() {
|
if self.windows.window(id).is_none() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-44
@@ -47,7 +47,14 @@ pub(crate) fn read_source(path: &Path) -> Result<String, String> {
|
|||||||
|
|
||||||
pub(crate) fn prepare(source: &str, directory: &Path) -> Result<(Config, RuntimeConfig, Candidate), String> {
|
pub(crate) fn prepare(source: &str, directory: &Path) -> Result<(Config, RuntimeConfig, Candidate), String> {
|
||||||
let document = Document::parse(source).map_err(|e| e.to_string())?;
|
let document = Document::parse(source).map_err(|e| e.to_string())?;
|
||||||
let candidate = resolve_with_context(&document, directory, Timestamp::now(), &auto_context(), read_source)?;
|
prepare_document(&document, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare_document(
|
||||||
|
document: &Document,
|
||||||
|
directory: &Path,
|
||||||
|
) -> Result<(Config, RuntimeConfig, Candidate), String> {
|
||||||
|
let candidate = resolve_with_context(document, directory, Timestamp::now(), &auto_context(), read_source)?;
|
||||||
let config: Config =
|
let config: Config =
|
||||||
serde_json::from_value(document.with_theme(&candidate.theme).value().clone()).map_err(|e| e.to_string())?;
|
serde_json::from_value(document.with_theme(&candidate.theme).value().clone()).map_err(|e| e.to_string())?;
|
||||||
let runtime = config.runtime_config().map_err(|e| e.to_string())?;
|
let runtime = config.runtime_config().map_err(|e| e.to_string())?;
|
||||||
@@ -131,6 +138,10 @@ pub(crate) struct Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
|
pub(crate) fn matches_files(&self, files: &[PathBuf]) -> bool {
|
||||||
|
self.active_files == files
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<Response>) {
|
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<Response>) {
|
||||||
if since != self.snapshot.revision {
|
if since != self.snapshot.revision {
|
||||||
let _ = response.try_send(Response::success(id, self.value()));
|
let _ = response.try_send(Response::success(id, self.value()));
|
||||||
@@ -163,7 +174,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn watch_files(&mut self, files: &[PathBuf]) {
|
pub(crate) fn watch_files(&mut self, files: &[PathBuf]) {
|
||||||
let mut targets = HashSet::from([PathBuf::from("/etc/localtime")]);
|
let mut targets = HashSet::from([PathBuf::from("/etc/localtime")]);
|
||||||
if let Some(path) = crate::config::config_path() {
|
if let Some(path) = crate::config::config_path() {
|
||||||
targets.insert(path);
|
targets.insert(path);
|
||||||
@@ -213,7 +224,7 @@ impl Engine {
|
|||||||
self.watched_directories = directories;
|
self.watched_directories = directories;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn arm_clock(&self, next: Option<Timestamp>) -> io::Result<()> {
|
pub(crate) fn arm_clock(&self, next: Option<Timestamp>) -> io::Result<()> {
|
||||||
let Some(fd) = &self.clock else { return Ok(()) };
|
let Some(fd) = &self.clock else { return Ok(()) };
|
||||||
let mut timer: libc::itimerspec = unsafe { std::mem::zeroed() };
|
let mut timer: libc::itimerspec = unsafe { std::mem::zeroed() };
|
||||||
if let Some(next) = next {
|
if let Some(next) = next {
|
||||||
@@ -342,47 +353,7 @@ pub(crate) fn init(
|
|||||||
|
|
||||||
impl Ferese {
|
impl Ferese {
|
||||||
pub(crate) fn refresh_theme(&mut self) {
|
pub(crate) fn refresh_theme(&mut self) {
|
||||||
let result = crate::config::config_path()
|
if let Err(error) = self.queue_config_reload(true, None) {
|
||||||
.map_or_else(
|
|
||||||
|| {
|
|
||||||
self.config_source
|
|
||||||
.clone()
|
|
||||||
.ok_or_else(|| "No configuration available".into())
|
|
||||||
},
|
|
||||||
|path| {
|
|
||||||
if path.exists() {
|
|
||||||
read_source(&path)
|
|
||||||
} else {
|
|
||||||
Ok(String::new())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.and_then(|source| {
|
|
||||||
if let Ok(document) = Document::parse(&source)
|
|
||||||
&& let Some(value) = document.get("theme")
|
|
||||||
&& let Ok(policy) = serde_json::from_value::<ferese_config::theme::Policy>(value.clone())
|
|
||||||
{
|
|
||||||
let directory = crate::config::config_path()
|
|
||||||
.and_then(|path| path.parent().map(ToOwned::to_owned))
|
|
||||||
.unwrap_or_else(|| PathBuf::from("."));
|
|
||||||
let files: Vec<_> = [&policy.file, &policy.light.file, &policy.dark.file]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.filter(|path| !path.as_os_str().is_empty())
|
|
||||||
.map(|path| ferese_config::theme::theme_path(&directory, path))
|
|
||||||
.collect();
|
|
||||||
let mut files = files;
|
|
||||||
files.extend(
|
|
||||||
policy
|
|
||||||
.custom_themes
|
|
||||||
.values()
|
|
||||||
.map(|theme| ferese_config::theme::theme_path(&directory, &theme.file)),
|
|
||||||
);
|
|
||||||
self.theme_engine.watch_files(&files);
|
|
||||||
}
|
|
||||||
self.reload_config_source(source)
|
|
||||||
});
|
|
||||||
if let Err(error) = result {
|
|
||||||
self.theme_engine.reject(error);
|
self.theme_engine.reject(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ impl Default for Snapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Candidate {
|
pub struct Candidate {
|
||||||
pub policy: Policy,
|
pub policy: Policy,
|
||||||
pub theme: ResolvedTheme,
|
pub theme: ResolvedTheme,
|
||||||
|
|||||||
@@ -1025,6 +1025,16 @@ fn finite_or_zero(value: f64) -> f64 {
|
|||||||
if value.is_finite() { value } else { 0.0 }
|
if value.is_finite() { value } else { 0.0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
pub fn window_count(&self) -> usize {
|
||||||
|
self.layout.window_ids().count() + self.floating.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.layout.window_ids().next().is_none() && self.floating.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1582,13 +1592,3 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Workspace {
|
|
||||||
pub fn window_count(&self) -> usize {
|
|
||||||
self.layout.window_ids().count() + self.floating.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.layout.window_ids().next().is_none() && self.floating.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,15 +90,16 @@ pub fn preview(
|
|||||||
selected: bool,
|
selected: bool,
|
||||||
) -> cosmic::widget::icon::Icon {
|
) -> cosmic::widget::icon::Icon {
|
||||||
use cosmic::widget::icon;
|
use cosmic::widget::icon;
|
||||||
let key = format!("{family:?}/{variant:?}/{active:?}/{selected}");
|
let key = format!("{}/{variant:?}/{active:?}/{selected}", family.id);
|
||||||
static CACHE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, icon::Handle>>> =
|
static CACHE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, (Family, icon::Handle)>>> =
|
||||||
std::sync::OnceLock::new();
|
std::sync::OnceLock::new();
|
||||||
let cache = CACHE.get_or_init(Default::default);
|
let cache = CACHE.get_or_init(Default::default);
|
||||||
let mut cache = cache.lock().unwrap();
|
let mut cache = cache.lock().unwrap();
|
||||||
let handle = cache
|
if cache.get(&key).is_none_or(|(cached, _)| cached != family) {
|
||||||
.entry(key)
|
let handle = icon::from_svg_bytes(svg(family, variant, active, selected).into_bytes()).symbolic(false);
|
||||||
.or_insert_with(|| icon::from_svg_bytes(svg(family, variant, active, selected).into_bytes()).symbolic(false))
|
cache.insert(key.clone(), (family.clone(), handle));
|
||||||
.clone();
|
}
|
||||||
|
let handle = cache[&key].1.clone();
|
||||||
// Bound cached previews when users repeatedly edit imported palettes.
|
// Bound cached previews when users repeatedly edit imported palettes.
|
||||||
if cache.len() > 128 {
|
if cache.len() > 128 {
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
|||||||
+1
-3
@@ -257,13 +257,11 @@ impl State {
|
|||||||
device.start_emulating(activation);
|
device.start_emulating(activation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("deactivated" | "disabled" | "closed") => {
|
Some("deactivated" | "disabled" | "closed") if self.activation.take().is_some() => {
|
||||||
if self.activation.take().is_some() {
|
|
||||||
for device in [&self.keyboard, &self.pointer].into_iter().flatten() {
|
for device in [&self.keyboard, &self.pointer].into_iter().flatten() {
|
||||||
device.stop_emulating();
|
device.stop_emulating();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Some("keys") if self.activation.is_some() => {
|
Some("keys") if self.activation.is_some() => {
|
||||||
if let Some(device) = &self.keyboard
|
if let Some(device) = &self.keyboard
|
||||||
&& let Some(keyboard) = device.interface::<eis::Keyboard>()
|
&& let Some(keyboard) = device.interface::<eis::Keyboard>()
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Input::Answer(id, accepted) => {
|
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();
|
return Task::none();
|
||||||
}
|
}
|
||||||
if accepted
|
if accepted
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod connections;
|
mod connections;
|
||||||
mod form_controls;
|
mod form_controls;
|
||||||
|
mod navigation;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod theme_controls;
|
mod theme_controls;
|
||||||
|
|
||||||
@@ -83,6 +84,8 @@ enum Message {
|
|||||||
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
||||||
DragWindow,
|
DragWindow,
|
||||||
Page(Page),
|
Page(Page),
|
||||||
|
PagePresented(Page),
|
||||||
|
RowVisibility(&'static str, usize, bool),
|
||||||
Search(String),
|
Search(String),
|
||||||
Change(Edit),
|
Change(Edit),
|
||||||
SelectFont(String, String),
|
SelectFont(String, String),
|
||||||
@@ -136,12 +139,18 @@ struct App {
|
|||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
status: String,
|
status: String,
|
||||||
page: Page,
|
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,
|
search: String,
|
||||||
inputs: HashMap<String, String>,
|
inputs: HashMap<String, String>,
|
||||||
ranges: HashMap<String, f64>,
|
ranges: HashMap<String, f64>,
|
||||||
new_command: String,
|
new_command: String,
|
||||||
font: cosmic::font::Font,
|
font: cosmic::font::Font,
|
||||||
native_palette: visuals::Palette,
|
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,
|
resolved: ferese_config::theme::Snapshot,
|
||||||
undo_revision: u64,
|
undo_revision: u64,
|
||||||
auto_details: bool,
|
auto_details: bool,
|
||||||
@@ -153,6 +162,19 @@ struct App {
|
|||||||
thumbnail_error: Option<String>,
|
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 {
|
impl Drop for App {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(client) = &self.connections.client {
|
if let Some(client) = &self.connections.client {
|
||||||
@@ -219,12 +241,18 @@ impl cosmic::Application for App {
|
|||||||
} else {
|
} else {
|
||||||
Page::Appearance
|
Page::Appearance
|
||||||
},
|
},
|
||||||
|
profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(),
|
||||||
|
page_transition: None,
|
||||||
search: String::new(),
|
search: String::new(),
|
||||||
|
visible_rows: initial_visible_rows(),
|
||||||
|
hidden_binding_rows: Default::default(),
|
||||||
inputs: HashMap::new(),
|
inputs: HashMap::new(),
|
||||||
ranges: HashMap::new(),
|
ranges: HashMap::new(),
|
||||||
new_command: String::new(),
|
new_command: String::new(),
|
||||||
font,
|
font,
|
||||||
native_palette,
|
native_palette,
|
||||||
|
gallery_focus: None,
|
||||||
|
family_ids: std::sync::Arc::new(resolved.families.iter().map(|family| family.id.clone()).collect()),
|
||||||
resolved,
|
resolved,
|
||||||
undo_revision: 0,
|
undo_revision: 0,
|
||||||
auto_details: false,
|
auto_details: false,
|
||||||
@@ -285,8 +313,17 @@ impl cosmic::Application for App {
|
|||||||
return self.edit_many(displays::edits(&prefix, mode, automatic));
|
return self.edit_many(displays::edits(&prefix, mode, automatic));
|
||||||
}
|
}
|
||||||
Message::ThemeChanged(snapshot) => {
|
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.resolved = *snapshot;
|
||||||
|
if font_changed {
|
||||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||||
|
}
|
||||||
|
|
||||||
return self.update_theme();
|
return self.update_theme();
|
||||||
}
|
}
|
||||||
Message::NoteAction(id, action) => {
|
Message::NoteAction(id, action) => {
|
||||||
@@ -396,9 +433,21 @@ impl cosmic::Application for App {
|
|||||||
self.error = Some(error);
|
self.error = Some(error);
|
||||||
}
|
}
|
||||||
Message::Page(page) => {
|
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;
|
let leaving = self.page == Page::Connections && page != Page::Connections;
|
||||||
self.page = page;
|
self.page = page;
|
||||||
self.search.clear();
|
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 {
|
if leaving {
|
||||||
let cleanup = self.leave_connections();
|
let cleanup = self.leave_connections();
|
||||||
let load = match page {
|
let load = match page {
|
||||||
@@ -406,16 +455,62 @@ impl cosmic::Application for App {
|
|||||||
Page::Wallpaper => self.load_thumbnail(),
|
Page::Wallpaper => self.load_thumbnail(),
|
||||||
_ => Task::none(),
|
_ => Task::none(),
|
||||||
};
|
};
|
||||||
return Task::batch([cleanup, load]);
|
return Task::batch([reset, cleanup, load]);
|
||||||
}
|
}
|
||||||
if page == Page::Connections {
|
if page == Page::Connections {
|
||||||
return self.refresh_connections();
|
return Task::batch([reset, self.refresh_connections()]);
|
||||||
}
|
}
|
||||||
if page == Page::Displays {
|
if page == Page::Displays {
|
||||||
return self.load_displays();
|
return Task::batch([reset, self.load_displays()]);
|
||||||
}
|
}
|
||||||
if page == Page::Wallpaper {
|
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,
|
Message::Search(query) => self.search = query,
|
||||||
@@ -437,6 +532,20 @@ impl cosmic::Application for App {
|
|||||||
}
|
}
|
||||||
Message::Commit(field) => {
|
Message::Commit(field) => {
|
||||||
if let Some(value) = self.inputs.remove(&field.path) {
|
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, .. }) {
|
let edit = if matches!(field.kind, Kind::Text { argv: true, .. }) {
|
||||||
match shlex::split(&value).filter(|v| !v.is_empty()) {
|
match shlex::split(&value).filter(|v| !v.is_empty()) {
|
||||||
Some(args) => set(
|
Some(args) => set(
|
||||||
@@ -520,11 +629,29 @@ impl cosmic::Application for App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Family(id, appearance) => {
|
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 edits = visuals::choose_family(&id, appearance);
|
||||||
let focus = cosmic::iced::advanced::widget::operate(
|
let focus = if self.gallery_focus.is_none() {
|
||||||
|
cosmic::iced::advanced::widget::operate(
|
||||||
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
|
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
|
||||||
);
|
)
|
||||||
return Task::batch([self.edit_many(edits), focus.map(cosmic::Action::App)]);
|
.map(cosmic::Action::App)
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
};
|
||||||
|
return Task::batch([self.edit_many(edits), reveal, focus]);
|
||||||
}
|
}
|
||||||
Message::AutoDetails(enabled) => self.auto_details = enabled,
|
Message::AutoDetails(enabled) => self.auto_details = enabled,
|
||||||
Message::AdvancedTheme(enabled) => self.advanced_theme = enabled,
|
Message::AdvancedTheme(enabled) => self.advanced_theme = enabled,
|
||||||
@@ -716,7 +843,9 @@ impl cosmic::Application for App {
|
|||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn update_theme(&mut self) -> Task<Message> {
|
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 {
|
if palette == self.native_palette {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
@@ -901,6 +1030,106 @@ mod tests {
|
|||||||
.0
|
.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]
|
#[test]
|
||||||
fn disabling_auto_keeps_the_effective_appearance() {
|
fn disabling_auto_keeps_the_effective_appearance() {
|
||||||
for appearance in [
|
for appearance in [
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -396,28 +396,44 @@ impl App {
|
|||||||
body = body.push(gestures);
|
body = body.push(gestures);
|
||||||
|
|
||||||
for index in 0..self.draft.records("bindings") {
|
for index in 0..self.draft.records("bindings") {
|
||||||
|
let content: Element<'static, Message> = if self.visible_rows.contains(&("bindings", index)) {
|
||||||
let prefix = format!("bindings.{index}");
|
let prefix = format!("bindings.{index}");
|
||||||
let group = column([])
|
let group = column([])
|
||||||
.spacing(1)
|
.spacing(1)
|
||||||
.push(self.field(schema::text(
|
.push(self.field(schema::text(
|
||||||
format!("{prefix}.keys"),
|
format!("{prefix}.keys"),
|
||||||
"Shortcut",
|
"Shortcut",
|
||||||
"For example: Super+Return or Swipe3Up",
|
"Keys or swipe, such as Super+Return",
|
||||||
"",
|
"",
|
||||||
)))
|
)))
|
||||||
.push(self.field(schema::text(
|
.push(self.field(schema::text(
|
||||||
format!("{prefix}.action"),
|
format!("{prefix}.action"),
|
||||||
"Action",
|
"Action",
|
||||||
"Action such as toggle-overview, spawn, focus, move, or none.",
|
"Action name, such as spawn or focus",
|
||||||
"",
|
"",
|
||||||
)))
|
)))
|
||||||
.push(self.field(schema::text(
|
.push(self.field(schema::text(
|
||||||
format!("{prefix}.argument"),
|
format!("{prefix}.argument"),
|
||||||
"Argument",
|
"Argument",
|
||||||
"Command name, direction, or action argument.",
|
"Command, direction, or action argument",
|
||||||
"",
|
"",
|
||||||
)));
|
)));
|
||||||
body = body.push(container(group).padding(4).class(visuals::surface(palette.card, 14.)));
|
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 {
|
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."));
|
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(
|
content = content.push(
|
||||||
scrollable(
|
scrollable(
|
||||||
container(body)
|
container(cosmic::iced::widget::keyed_column([(self.page as usize, body.into())]))
|
||||||
.padding(cosmic::iced::Padding {
|
.padding(cosmic::iced::Padding {
|
||||||
top: 0.,
|
top: 0.,
|
||||||
right: 12.,
|
right: 12.,
|
||||||
@@ -507,7 +523,7 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
footer.into()
|
footer.into()
|
||||||
};
|
};
|
||||||
row([])
|
let view: Element<'_, Message> = row([])
|
||||||
.push(sidebar)
|
.push(sidebar)
|
||||||
.push(
|
.push(
|
||||||
container(
|
container(
|
||||||
@@ -527,6 +543,15 @@ impl App {
|
|||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.class(visuals::surface(palette.background, 0.)),
|
.class(visuals::surface(palette.background, 0.)),
|
||||||
)
|
)
|
||||||
|
.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()
|
.into()
|
||||||
|
} else {
|
||||||
|
view
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,15 +156,9 @@ impl App {
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn theme_gallery(
|
pub(super) fn theme_gallery(&self, appearance: Option<ferese_config::theme::Appearance>) -> Element<'_, Message> {
|
||||||
&self,
|
|
||||||
appearance: Option<ferese_config::theme::Appearance>,
|
|
||||||
) -> Element<'static, Message> {
|
|
||||||
use ferese_config::theme::Appearance::{Dark, Light};
|
use ferese_config::theme::Appearance::{Dark, Light};
|
||||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
let choices = &self.resolved.families;
|
||||||
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 mut heading = row([]).align_y(Alignment::Center).spacing(6).push(
|
let mut heading = row([]).align_y(Alignment::Center).spacing(6).push(
|
||||||
self.label(
|
self.label(
|
||||||
match appearance {
|
match appearance {
|
||||||
@@ -192,7 +186,51 @@ impl App {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut gallery = column([]).spacing(6).push(heading);
|
let mut gallery = column([]).spacing(6).push(heading);
|
||||||
for (chunk, families) in choices.chunks(3).enumerate() {
|
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),
|
||||||
|
cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")),
|
||||||
|
match appearance {
|
||||||
|
Some(Light) => "Light theme",
|
||||||
|
Some(Dark) => "Dark theme",
|
||||||
|
None => "Theme",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
let mut tiles = row([]).spacing(6);
|
||||||
for (offset, family) in families.iter().enumerate() {
|
for (offset, family) in families.iter().enumerate() {
|
||||||
let selected = family.id == selected_id;
|
let selected = family.id == selected_id;
|
||||||
@@ -201,8 +239,8 @@ impl App {
|
|||||||
let active = selected
|
let active = selected
|
||||||
.then_some(self.resolved.theme.appearance)
|
.then_some(self.resolved.theme.appearance)
|
||||||
.filter(|a| appearance.is_none_or(|variant| variant == *a));
|
.filter(|a| appearance.is_none_or(|variant| variant == *a));
|
||||||
let ids = ids.clone();
|
|
||||||
let tile_id = gallery_id(&id, appearance);
|
let tile_id = gallery_id(&id, appearance);
|
||||||
|
let ids = ids.clone();
|
||||||
tiles = tiles.push(gallery::tile(
|
tiles = tiles.push(gallery::tile(
|
||||||
family,
|
family,
|
||||||
gallery::TileOptions {
|
gallery::TileOptions {
|
||||||
@@ -223,17 +261,8 @@ impl App {
|
|||||||
for _ in families.len()..3 {
|
for _ in families.len()..3 {
|
||||||
tiles = tiles.push(widget::Space::new().width(Length::Fill));
|
tiles = tiles.push(widget::Space::new().width(Length::Fill));
|
||||||
}
|
}
|
||||||
gallery = gallery.push(tiles);
|
|
||||||
}
|
tiles.into()
|
||||||
gallery::group(
|
|
||||||
container(gallery).width(Length::Fill),
|
|
||||||
cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")),
|
|
||||||
match appearance {
|
|
||||||
Some(Light) => "Light theme",
|
|
||||||
Some(Dark) => "Dark theme",
|
|
||||||
None => "Theme",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user