Add searchable input controls and display profile editing

This commit is contained in:
aria committed 2026-10-28 08:19:05 +09:00
1 parent 78ce1ed590
commit a2be421974
24 files changed
+3426 -249

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] }
iced_accessibility.workspace = true
ferese-ipc = { path = "../../crates/ferese-ipc" }
serde_json.workspace = true
wayland-client.workspace = true
tempfile.workspace = true
shlex = "1.3.0"
notify = "8.2.0"
@@ -0,0 +1,342 @@
use crate::{
App, Message, Snapshot, Task,
store::{Edit, set},
};
#[derive(Default)]
pub(super) struct State {
pub busy: bool,
pub recovery: Option<Snapshot>,
pub restoring: bool,
restore_after_revert: bool,
pub queued: Option<Command>,
}
#[derive(Clone, Debug)]
pub(super) enum Command {
Confirm,
Revert,
Profile(String),
}
#[derive(Clone, Debug)]
pub(super) enum Input {
Create,
Rename(usize),
Delete(usize),
Position(String),
AutoPosition(String),
Activate(String),
Confirm,
Revert,
Restore,
}
fn profile_index(prefix: &str) -> Option<usize> {
prefix
.strip_prefix("output_profiles.")?
.split('.')
.next()?
.parse()
.ok()
}
pub(super) fn affects_outputs(edit: &Edit) -> bool {
let path = match edit {
Edit::Set(p, _) | Edit::Unset(p) | Edit::Add(p, _) | Edit::Remove(p, _) => p,
};
path == "output_profiles" || path.starts_with("output_profiles.")
}
impl App {
pub(super) fn remember_display_config(&mut self) {
if self.page == crate::schema::Page::Displays
&& !self.display_state.restoring
&& self.display_state.recovery.is_none()
{
self.display_state.recovery = Some(self.current.clone());
}
}
pub(super) fn display_input(&mut self, input: Input) -> Task<Message> {
if self.saving || self.display_state.busy {
return Task::none();
}
match input {
Input::Create => match crate::displays::profile(&self.draft, &self.displays) {
Ok((name, edit)) => {
let matcher = self
.displays
.iter()
.find(|d| d.connected)
.map(|d| crate::displays::selector(d, &self.displays))
.map(str::to_owned)
.unwrap_or_default();
self.display_selection = Some(format!("{name}\0{matcher}"));
return self.change(edit);
}
Err(error) => self.error = Some(error),
},
Input::Rename(index) => {
let old = self
.draft
.string(&format!("output_profiles.{index}.name"), "");
let name = self
.inputs
.get("$display.profile_name")
.map(|v| v.trim())
.unwrap_or(&old)
.to_owned();
if name.is_empty()
|| (0..self.draft.records("output_profiles")).any(|i| {
i != index
&& self.draft.string(&format!("output_profiles.{i}.name"), "") == name
})
{
self.error = Some("Choose a unique profile name".into());
return Task::none();
}
if name == old {
self.inputs.remove("$display.profile_name");
return Task::none();
}
if self
.displays
.iter()
.any(|d| d.hardware && d.profile.as_deref() == Some(&old))
{
self.display_state.queued = Some(Command::Profile(name.clone()));
}
if let Some(key) = self
.display_selection
.as_ref()
.and_then(|k| k.split_once('\0'))
.map(|(_, selector)| selector.to_owned())
{
self.display_selection = Some(format!("{name}\0{key}"));
}
self.inputs.remove("$display.profile_name");
return self.change(set(&format!("output_profiles.{index}.name"), name));
}
Input::Delete(index) => {
let name = self
.draft
.string(&format!("output_profiles.{index}.name"), "");
if self
.displays
.iter()
.any(|d| d.hardware && d.profile.as_deref() == Some(&name))
{
self.display_state.queued = Some(Command::Profile("auto".into()));
}
self.display_selection = None;
self.inputs.retain(|key, _| !key.starts_with("$display."));
return self.change(Edit::Remove("output_profiles".into(), index));
}
Input::Position(prefix) => {
let matcher = self.draft.string(&format!("{prefix}.match"), "");
let live = self
.displays
.iter()
.find(|d| d.connector == matcher || d.identity == matcher);
let saved = crate::displays::configured_position(&self.draft, &prefix, live);
let mut position = [0, 0];
for (i, axis) in ["x", "y"].into_iter().enumerate() {
let key = format!("$display.{prefix}.{axis}");
let value = self
.inputs
.get(&key)
.cloned()
.unwrap_or_else(|| saved[i].to_string());
match value.parse::<i32>() {
Ok(v) => position[i] = v,
Err(_) => {
self.error = Some("Position must use whole logical pixels".into());
return Task::none();
}
}
}
for axis in ["x", "y"] {
self.inputs.remove(&format!("$display.{prefix}.{axis}"));
}
return self.change(set(
&format!("{prefix}.position"),
serde_json::json!(position),
));
}
Input::AutoPosition(prefix) => {
return self.change(Edit::Unset(format!("{prefix}.position")));
}
Input::Activate(name) => return self.display_command(Command::Profile(name)),
Input::Confirm => return self.display_command(Command::Confirm),
Input::Revert => {
self.display_state.restore_after_revert = self.display_state.recovery.is_some();
return self.display_command(Command::Revert);
}
Input::Restore => return self.restore_display_config(),
}
Task::none()
}
fn restore_display_config(&mut self) -> Task<Message> {
let Some(previous) = self.display_state.recovery.as_ref() else {
return Task::none();
};
let mut restored = self.current.doc.clone();
if let Err(error) = restored.restore_top_level("output_profiles", &previous.doc) {
self.error = Some(error.to_string());
return Task::none();
}
self.display_state.restoring = true;
self.start_save(restored.to_string())
}
pub(super) fn display_command(&mut self, command: Command) -> Task<Message> {
if self.display_state.busy {
return Task::none();
}
self.display_state.busy = true;
cosmic::task::future(async move {
let operation = command.clone();
let result = tokio::task::spawn_blocking(move || {
let mut connection =
ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
let (name, args) = match operation {
Command::Confirm => ("output-confirm", serde_json::json!({})),
Command::Revert => ("output-revert", serde_json::json!({})),
Command::Profile(name) => ("output-profile", serde_json::json!({"name":name})),
};
connection.call(name, args).map(|_| ())
})
.await
.unwrap_or_else(|e| Err(e.to_string()));
Message::DisplayCommandFinished(command, result)
})
}
pub(super) fn display_command_finished(
&mut self,
command: Command,
result: Result<(), String>,
) -> Task<Message> {
self.display_state.busy = false;
if let Err(error) = result {
self.error = Some(error);
self.display_state.restore_after_revert = false;
return self.refresh_displays(true);
}
match command {
Command::Confirm => {
self.display_state.recovery = None;
}
Command::Revert if self.display_state.restore_after_revert => {
self.display_state.restore_after_revert = false;
return self.restore_display_config();
}
_ => {}
}
self.refresh_displays(true)
}
}
pub(super) fn index(prefix: &str) -> Option<usize> {
profile_index(prefix)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn position_keeps_the_other_live_axis_and_rejects_invalid_edits_together() {
let mut app = <App as cosmic::Application>::init(
cosmic::app::Core::default(),
(
"/unused/display-position.kdl".into(),
Snapshot::parse("output-profile \"desk\" { output match=\"DP-3\"; }".into()),
None,
None,
),
)
.0;
app.displays = vec![crate::displays::Display {
connector: "DP-3".into(),
position: Some([-1920, 120]),
connected: true,
enabled: true,
..Default::default()
}];
let prefix = "output_profiles.0.outputs.0";
app.inputs
.insert(format!("$display.{prefix}.x"), "-2560".into());
app.inputs
.insert(format!("$display.{prefix}.y"), "invalid".into());
drop(app.display_input(Input::Position(prefix.into())));
assert!(app.draft.item(&format!("{prefix}.position")).is_none());
assert_eq!(app.inputs[&format!("$display.{prefix}.x")], "-2560");
app.inputs.remove(&format!("$display.{prefix}.y"));
drop(app.display_input(Input::Position(prefix.into())));
assert_eq!(
app.draft.item(&format!("{prefix}.position")),
Some(&serde_json::json!([-2560, 120]))
);
}
#[test]
fn duplicate_monitor_identities_use_connectors_and_enabled_guard_reads_the_draft() {
let displays = vec![
crate::displays::Display {
connector: "DP-1".into(),
identity: "Identical monitor".into(),
connected: true,
enabled: true,
..Default::default()
},
crate::displays::Display {
connector: "DP-2".into(),
identity: "Identical monitor".into(),
connected: true,
enabled: true,
..Default::default()
},
];
assert_eq!(crate::displays::selector(&displays[1], &displays), "DP-2");
let snapshot=Snapshot::parse("output-profile \"desk\" { output match=\"DP-1\" enabled=#false; output match=\"DP-2\" enabled=#true; }".into()).unwrap();
assert_eq!(
crate::displays::enabled_count(&snapshot, &displays, Some("desk")),
1
);
assert_eq!(
crate::displays::enabled_count(&snapshot, &displays, None),
2
);
}
#[test]
fn profiles_capture_live_identity_and_layout_without_replacing_existing_profiles() {
let mut snapshot = Snapshot::parse(
"// keep\noutput-profile \"Other desk\" { output match=\"HDMI-A-1\"; }".into(),
)
.unwrap();
let displays = vec![crate::displays::Display {
connector: "DP-3".into(),
identity: "Vendor Model Serial".into(),
connected: true,
enabled: true,
position: Some([-1920, 0]),
scale: 2.,
transform: "90".into(),
current: Some(crate::displays::Mode {
width: 3840,
height: 2160,
refresh: 144000,
}),
..Default::default()
}];
let (_, edit) = crate::displays::profile(&snapshot, &displays).unwrap();
snapshot.edit(&edit).unwrap();
assert_eq!(snapshot.records("output_profiles"), 2);
assert!(snapshot.source.contains("// keep"));
assert_eq!(
snapshot.string("output_profiles.1.outputs.0.match", ""),
"Vendor Model Serial"
);
assert_eq!(
snapshot
.item("output_profiles.1.outputs.0.position")
.unwrap(),
&serde_json::json!([-1920, 0])
);
assert_eq!(
snapshot.string("output_profiles.1.outputs.0.transform", ""),
"rotate_90"
);
}
}
+169
View File
@@ -0,0 +1,169 @@
use crate::displays::Display;
use crate::{App, Element, Length, Message, visuals};
use cosmic::widget::{button, column, container};
fn dimensions(display: &Display) -> (f32, f32) {
let scale = display.scale.max(0.25);
let mut size = display
.current
.map(|m| (m.width as f64 / scale, m.height as f64 / scale))
.unwrap_or((1920., 1080.));
if ["rotate_90", "rotate_270", "flipped_90", "flipped_270"]
.contains(&display.transform.as_str())
{
size = (size.1, size.0);
}
(
display.logical_width.unwrap_or(size.0 as i32).max(1) as f32,
display.logical_height.unwrap_or(size.1 as i32).max(1) as f32,
)
}
pub(super) fn positions(
displays: &[Display],
width: f32,
height: f32,
) -> Vec<(usize, cosmic::iced::Rectangle)> {
let mut next = 0.;
let raw: Vec<_> = displays
.iter()
.enumerate()
.filter(|(_, d)| d.connected && d.enabled)
.map(|(index, display)| {
let (w, h) = dimensions(display);
let (x, y) = display
.position
.map(|[x, y]| (x as f32, y as f32))
.unwrap_or((next, 0.));
next = x + w;
(index, x, y, w, h)
})
.collect();
if raw.is_empty() {
return Vec::new();
}
let left = raw.iter().map(|r| r.1).fold(f32::INFINITY, f32::min);
let top = raw.iter().map(|r| r.2).fold(f32::INFINITY, f32::min);
let right = raw
.iter()
.map(|r| r.1 + r.3)
.fold(f32::NEG_INFINITY, f32::max);
let bottom = raw
.iter()
.map(|r| r.2 + r.4)
.fold(f32::NEG_INFINITY, f32::max);
let scale = ((width - 24.).max(1.) / (right - left).max(1.))
.min((height - 24.).max(1.) / (bottom - top).max(1.));
let ox = (width - (right - left) * scale) / 2.;
let oy = (height - (bottom - top) * scale) / 2.;
raw.into_iter()
.map(|(i, x, y, w, h)| {
(
i,
cosmic::iced::Rectangle {
x: ox + (x - left) * scale,
y: oy + (y - top) * scale,
width: (w * scale - 4.).max(1.),
height: (h * scale - 4.).max(1.),
},
)
})
.collect()
}
impl App {
pub(super) fn display_map(&self) -> Element<'_, Message> {
cosmic::iced::widget::responsive(move |size| {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let targets = crate::displays::targets(&self.draft, &self.displays);
let selected = crate::displays::selected(&targets, self.display_selection.as_deref());
let mut layers: Vec<Element<'_, Message>> = vec![
container(cosmic::widget::Space::new().width(Length::Fill).height(170))
.class(visuals::surface(p.sidebar, 5.))
.into(),
];
for (i, rect) in positions(&self.displays, size.width, 170.) {
let display = &self.displays[i];
let matches = |t: &&crate::displays::Target| {
t.connected && (t.matcher == display.connector || t.matcher == display.identity)
};
let target = targets
.iter()
.filter(matches)
.find(|t| selected.is_some_and(|s| s.profile == t.profile))
.or_else(|| targets.iter().filter(matches).find(|t| t.active))
.or_else(|| targets.iter().find(matches));
let active = selected.is_some_and(|t| {
t.matcher == display.connector || t.matcher == display.identity
});
let label = column([])
.spacing(4)
.align_x(cosmic::iced::Alignment::Center)
.push(self.label(display.connector.clone(), 12.))
.push(self.label(
format!(
"{} × {}",
dimensions(display).0 as i32,
dimensions(display).1 as i32
),
10.5,
));
let tile = button::custom(
container(label)
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.name(display.connector.clone())
.width(rect.width)
.height(rect.height)
.padding(4)
.class(visuals::button_style(p, active))
.on_press_maybe(target.map(|t| Message::SelectDisplay(t.key.clone())));
layers.push(cosmic::iced::widget::pin(tile).x(rect.x).y(rect.y).into());
}
cosmic::iced::widget::stack(layers)
.width(Length::Fill)
.height(170)
.into()
})
.width(Length::Fill)
.height(170)
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_preserves_negative_positions_rotation_and_monitor_order() {
let displays: Vec<_> = [-1920, 0, 1920]
.into_iter()
.map(|x| Display {
position: Some([x, 0]),
logical_width: Some(1920),
logical_height: Some(1080),
enabled: true,
connected: true,
..Default::default()
})
.collect();
let rects = positions(&displays, 900., 170.);
assert_eq!(rects.len(), 3);
for pair in rects.windows(2) {
assert!(pair[0].1.x + pair[0].1.width < pair[1].1.x)
}
assert!(rects.iter().all(|(_, r)| r.x >= 0.
&& r.y >= 0.
&& r.x + r.width <= 900.
&& r.y + r.height <= 170.));
let rotated = Display {
current: Some(crate::displays::Mode {
width: 3840,
height: 2160,
refresh: 60000,
}),
scale: 2.,
transform: "rotate_90".into(),
..Default::default()
};
assert_eq!(dimensions(&rotated), (1080., 1920.));
}
}
+165 -4
View File
@@ -36,12 +36,21 @@ impl Mode {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Default)]
pub struct Display {
pub connector: String,
pub identity: String,
pub profile: Option<String>,
pub logical_width: Option<i32>,
pub logical_height: Option<i32>,
pub position: Option<[i32; 2]>,
pub scale: f64,
pub transform: String,
pub enabled: bool,
pub connected: bool,
pub hardware: bool,
pub confirmation_pending: bool,
pub configuration_error: Option<String>,
pub focused: bool,
pub current: Option<Mode>,
pub modes: Vec<Mode>,
@@ -62,6 +71,21 @@ pub fn load() -> Result<Vec<Display>, String> {
.as_i64()
.and_then(|width| width.try_into().ok())
.filter(|width| *width > 0),
logical_height: value["height"]
.as_i64()
.and_then(|v| v.try_into().ok())
.filter(|h| *h > 0),
position: value["x"]
.as_i64()
.zip(value["y"].as_i64())
.and_then(|(x, y)| Some([x.try_into().ok()?, y.try_into().ok()?])),
scale: value["scale"].as_f64().unwrap_or(1.),
transform: config_transform(value["transform"].as_str().unwrap_or("normal")).into(),
enabled: value["enabled"].as_bool().unwrap_or(true),
connected: value["connected"].as_bool().unwrap_or(true),
hardware: value.get("requested_enabled").is_some(),
confirmation_pending: value["confirmation_pending"].as_bool().unwrap_or(false),
configuration_error: value["configuration_error"].as_str().map(str::to_owned),
focused: value["focused"].as_bool().unwrap_or(false),
current: Mode::from_value(&value["current_mode"]),
modes: value["available_modes"]
@@ -75,6 +99,118 @@ pub fn load() -> Result<Vec<Display>, String> {
.collect())
}
pub(super) fn config_transform(value: &str) -> &str {
match value {
"90" => "rotate_90",
"180" => "rotate_180",
"270" => "rotate_270",
"flipped-90" => "flipped_90",
"flipped-180" => "flipped_180",
"flipped-270" => "flipped_270",
other => other,
}
}
pub(super) fn profile(
snapshot: &crate::store::Snapshot,
displays: &[Display],
) -> Result<(String, Edit), String> {
let connected: Vec<_> = displays.iter().filter(|d| d.connected).collect();
if connected.is_empty() {
return Err("Connect a display before creating a profile".into());
}
let mut serial = 1;
let name = loop {
let name =
ferese_config::locale::format("Desktop {number}", &[("number", serial.to_string())]);
if !(0..snapshot.records("output_profiles"))
.any(|i| snapshot.string(&format!("output_profiles.{i}.name"), "") == name)
{
break name;
}
serial += 1;
};
let outputs:Vec<_>=connected.into_iter().map(|d|{
let mut value=serde_json::json!({"match":selector(d,displays),"enabled":d.enabled,"required":true,"scale":if d.scale>0.{d.scale}else{1.},"transform":config_transform(if d.transform.is_empty(){"normal"}else{&d.transform})});
if let Some(mode)=d.current {value["mode"]=mode.config().into();}
if let Some(position)=d.position {value["position"]=serde_json::json!(position);}
value
}).collect();
Ok((
name.clone(),
Edit::Add(
"output_profiles".into(),
vec![
("name".into(), name.into()),
("layout".into(), "extend".into()),
("confirm_timeout".into(), 15.into()),
("outputs".into(), outputs.into()),
],
),
))
}
pub(super) fn selector<'a>(display: &'a Display, all: &[Display]) -> &'a str {
if !display.identity.is_empty()
&& all
.iter()
.filter(|d| d.connected && d.identity == display.identity)
.count()
== 1
{
&display.identity
} else {
&display.connector
}
}
pub(super) fn configured_position(
snapshot: &crate::Snapshot,
prefix: &str,
live: Option<&Display>,
) -> [i32; 2] {
let value = snapshot
.item(&format!("{prefix}.position"))
.and_then(|v| v.as_array());
value
.and_then(|a| {
Some([
a.first()?.as_i64()?.try_into().ok()?,
a.get(1)?.as_i64()?.try_into().ok()?,
])
})
.or_else(|| live.and_then(|d| d.position))
.unwrap_or([0, 0])
}
/// Count the draft's enabled connected outputs, including edits that have not
/// reached the compositor yet. Unmatched outputs keep their current state.
pub(super) fn enabled_count(
snapshot: &crate::Snapshot,
displays: &[Display],
profile: Option<&str>,
) -> usize {
let index = profile.and_then(|name| {
(0..snapshot.records("output_profiles"))
.find(|i| snapshot.string(&format!("output_profiles.{i}.name"), "") == name)
});
displays
.iter()
.filter(|d| d.connected)
.filter(|d| {
let output = index.and_then(|i| {
let prefix = format!("output_profiles.{i}.outputs");
(0..snapshot.records(&prefix)).find_map(|j| {
let path = format!("{prefix}.{j}");
let matcher = snapshot.string(&format!("{path}.match"), "");
(matcher == d.connector || matcher == d.identity)
.then(|| snapshot.boolean(&format!("{path}.enabled"), true))
})
});
output.unwrap_or(d.enabled)
})
.count()
}
pub fn choices(display: &Display, configured: &str) -> Vec<Mode> {
let size = configured
.split('@')
@@ -117,8 +253,14 @@ impl std::fmt::Display for Target {
f,
"{} · {}{}",
self.title,
self.profile.as_deref().unwrap_or("Automatic"),
if self.connected { "" } else { " (offline)" }
self.profile
.as_deref()
.unwrap_or(ferese_config::tr!("Automatic")),
if self.connected {
String::new()
} else {
format!(" · {}", ferese_config::tr!("Offline"))
}
)
}
}
@@ -133,7 +275,7 @@ pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) -
let matcher = snapshot.string(&format!("{prefix}.match"), "");
let live = displays
.iter()
.find(|d| d.connector == matcher || d.identity == matcher);
.find(|d| d.connected && (d.connector == matcher || d.identity == matcher));
targets.push(Target {
key: format!("{name}\0{matcher}"),
title: live
@@ -148,6 +290,9 @@ pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) -
}
}
for display in displays {
if !display.connected {
continue;
}
if !targets
.iter()
.any(|t| t.matcher == display.connector || t.matcher == display.identity)
@@ -310,6 +455,10 @@ mod tests {
refresh: 59940,
},
],
connected: true,
enabled: true,
scale: 1.,
..Default::default()
};
let choices = choices(&display, "2880x1800@120");
assert_eq!(choices.len(), 2);
@@ -375,6 +524,10 @@ mod tests {
focused: true,
current: None,
modes: vec![],
connected: true,
enabled: true,
scale: 1.,
..Default::default()
}];
let values = targets(&snapshot, &live);
assert_eq!(
@@ -407,6 +560,10 @@ mod tests {
focused: true,
current: None,
modes: vec![],
connected: true,
enabled: true,
scale: 1.,
..Default::default()
}];
let values = targets(&snapshot, &live);
assert_eq!(values.len(), 1);
@@ -432,6 +589,10 @@ mod tests {
mode(2880, 1800, 120000),
mode(2560, 1440, 90000),
],
connected: true,
enabled: true,
scale: 1.,
..Default::default()
};
assert_eq!(resolutions(&display).len(), 2);
let high = Resolution {
+225 -5
View File
@@ -10,7 +10,48 @@ impl App {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let targets = displays::targets(&self.draft, &self.displays);
let selected = displays::selected(&targets, self.display_selection.as_deref()).cloned();
let mut panel = column([]).spacing(6);
let pending = self.displays.iter().any(|d| d.confirmation_pending);
let available = !self.saving && !self.display_state.busy;
let mut panel = column([])
.spacing(10)
.push(self.panel_heading(ferese_config::tr!("Current display arrangement"), 14.))
.push(self.display_map());
if pending {
panel =
panel
.push(self.note(
"Keep these display settings? Unconfirmed changes revert automatically.",
))
.push(
row([])
.spacing(8)
.push(self.settings_button(
"Keep changes",
"M5 12l4 4L19 6",
available.then_some(Message::DisplayInput(
crate::display_controls::Input::Confirm,
)),
true,
))
.push(self.settings_button(
"Revert changes",
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
available.then_some(Message::DisplayInput(
crate::display_controls::Input::Revert,
)),
false,
)),
);
} else if self.display_state.recovery.is_some() {
panel = panel.push(self.settings_button(
"Restore previous display configuration",
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
available.then_some(Message::DisplayInput(
crate::display_controls::Input::Restore,
)),
false,
));
}
panel = panel.push(
row([])
.spacing(8)
@@ -26,7 +67,7 @@ impl App {
selected.clone(),
|target| Message::SelectDisplay(target.key),
)
.placeholder("Select display")
.placeholder(ferese_config::tr!("Select display"))
.font(self.font)
.text_size(12)
.padding([4, 8])
@@ -41,6 +82,14 @@ impl App {
Some(Message::RefreshDisplays),
)),
);
panel = panel.push(self.settings_button(
"Save current layout as a profile",
"M12 5v14 M5 12h14",
(available && self.displays.iter().any(|d| d.connected)).then_some(
Message::DisplayInput(crate::display_controls::Input::Create),
),
false,
));
if let Some(error) = &self.display_error {
panel = panel.push(cosmic::widget::tooltip(
self.note(ferese_config::tr!("Live display information unavailable")),
@@ -59,8 +108,119 @@ impl App {
.map(|mode| format!("{} × {} · {}", mode.width, mode.height, mode.label()))
.unwrap_or_else(|| "Off".into());
panel = panel.push(self.note(&status));
if !display.identity.is_empty() {
panel = panel.push(self.note(&display.identity));
}
if let Some(error) = &display.configuration_error {
panel = panel.push(self.note(error));
}
if !display.hardware {
panel=panel.push(self.note("This display is managed by the host session. Saved profiles apply in a standalone session."));
}
}
if let Some(prefix) = target.prefix {
if let Some(index) = crate::display_controls::index(&prefix) {
let profile_prefix = format!("output_profiles.{index}");
let name = self
.inputs
.get("$display.profile_name")
.cloned()
.unwrap_or_else(|| {
self.draft.string(&format!("{profile_prefix}.name"), "")
});
panel = panel
.push(self.panel_heading(ferese_config::tr!("Display profile"), 14.))
.push(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(
cosmic::widget::text_input(
ferese_config::tr!("Profile name"),
name,
)
.font(self.font)
.size(12)
.style(visuals::input_style(palette))
.on_input(|name| {
Message::Draft("$display.profile_name".into(), name)
})
.on_submit(move |_| {
Message::DisplayInput(
crate::display_controls::Input::Rename(index),
)
})
.width(Length::Fill),
)
.push(self.settings_icon_button(
"Rename profile",
"M4 20l4-1L20 7l-3-3L5 16z",
(available && !pending).then_some(Message::DisplayInput(
crate::display_controls::Input::Rename(index),
)),
))
.push(self.settings_icon_button(
"Remove profile",
"M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13",
(available && !pending).then_some(Message::DisplayInput(
crate::display_controls::Input::Delete(index),
)),
)),
);
if live.is_some_and(|d| d.hardware) && !target.active {
panel = panel.push(self.settings_button(
"Use this profile",
"M5 12l4 4L19 6",
(available && !pending).then_some(Message::DisplayInput(
crate::display_controls::Input::Activate(
target.profile.clone().unwrap_or_default(),
),
)),
true,
));
}
panel = panel.push(self.field(schema::choice(
&format!("{profile_prefix}.layout"),
"Display layout",
"",
"extend",
&[
("extend", "Extend"),
("mirror", "Mirror"),
("internal-only", "Internal display only"),
("external-only", "External displays only"),
],
)));
}
panel = panel.push(self.panel_heading(ferese_config::tr!("Output settings"), 14.));
let enabled = self.draft.boolean(&format!("{prefix}.enabled"), true);
let can_disable = !enabled
|| !target.active
|| displays::enabled_count(
&self.draft,
&self.displays,
target.profile.as_deref(),
) > 1;
let enable_path = format!("{prefix}.enabled");
panel = panel.push(
self.display_control(
"Enable display",
container(
ferese_theme::controls::switch(enabled, palette)
.name(ferese_config::tr!("Enable display"))
.on_press_maybe((available && can_disable).then_some(
Message::Change(crate::store::set(&enable_path, !enabled)),
)),
)
.width(225)
.align_right(Length::Fill)
.into(),
),
);
if enabled && !can_disable {
panel = panel
.push(self.note("At least one connected display must remain enabled."));
}
let configured = self.draft.string(&format!("{prefix}.mode"), "");
let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false);
if let Some(display) = live {
@@ -150,13 +310,73 @@ impl App {
"",
schema::RangeSpec {
default: 1.,
min: 0.75,
max: 3.,
step: 0.05,
min: 0.5,
max: 4.,
step: 0.25,
suffix: "×",
integer: false,
},
)));
panel = panel.push(self.field(schema::choice(
&format!("{prefix}.transform"),
"Rotation and reflection",
"",
"normal",
&[
("normal", "Landscape"),
("rotate_90", "Portrait right"),
("rotate_180", "Landscape inverted"),
("rotate_270", "Portrait left"),
("flipped", "Reflected landscape"),
("flipped_90", "Reflected portrait right"),
("flipped_180", "Reflected landscape inverted"),
("flipped_270", "Reflected portrait left"),
],
)));
let saved = displays::configured_position(&self.draft, &prefix, live);
let mut position = row([])
.spacing(8)
.align_y(Alignment::Center)
.push(self.label(ferese_config::tr!("Position"), 13.));
for (i, axis) in ["x", "y"].into_iter().enumerate() {
let key = format!("$display.{prefix}.{axis}");
let fallback = saved[i];
let value = self
.inputs
.get(&key)
.cloned()
.unwrap_or_else(|| fallback.to_string());
let path = prefix.clone();
position = position.push(
cosmic::widget::text_input(axis, value)
.font(self.font)
.size(12)
.style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(key.clone(), value))
.on_submit(move |_| {
Message::DisplayInput(crate::display_controls::Input::Position(
path.clone(),
))
})
.width(Length::Fill),
);
}
position = position
.push(self.settings_icon_button(
"Apply position",
"M5 12l4 4L19 6",
available.then_some(Message::DisplayInput(
crate::display_controls::Input::Position(prefix.clone()),
)),
))
.push(self.settings_icon_button(
"Automatic position",
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
available.then_some(Message::DisplayInput(
crate::display_controls::Input::AutoPosition(prefix.clone()),
)),
));
panel=panel.push(position).push(self.note("Positions use logical pixels after scaling and rotation. Negative values are supported."));
panel = panel.push(row([]).spacing(5).align_y(Alignment::Center)
.push(cosmic::widget::tooltip(visuals::action_icon("M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 11v6 M12 7h.01", palette.muted),
self.label(ferese_config::tr!("Auto uses 60 Hz below 30% battery and returns to normal on AC or at 35%. Applying a display mode can briefly blank the screen."), 11.), cosmic::widget::tooltip::Position::Top))
@@ -0,0 +1,604 @@
use crate::{
App, Element, Length, Message, Snapshot, Task,
store::{Edit, set},
visuals,
};
use cosmic::widget::{column, combo_box, container, row};
use std::{
collections::BTreeMap,
io::Read,
sync::{Arc, OnceLock},
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Choice {
pub code: String,
pub name: String,
translated: Option<String>,
}
impl std::fmt::Display for Choice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = if ferese_config::locale::resolved() == ferese_config::locale::Language::Chinese
{
self.translated
.clone()
.unwrap_or_else(|| ferese_config::locale::translate(&self.name))
} else {
self.name.clone()
};
if self.code.is_empty() {
f.write_str(&name)
} else {
write!(f, "{} · {}", name, self.code)
}
}
}
#[derive(Default, Debug)]
pub(super) struct Catalog {
layouts: Vec<Choice>,
variants: BTreeMap<String, Vec<Choice>>,
switches: Vec<Choice>,
}
impl Catalog {
fn parse(source: &str) -> Self {
let mut result = Self::default();
let mut section = "";
for line in source.lines().map(str::trim).filter(|s| !s.is_empty()) {
if let Some(header) = line.strip_prefix('!') {
section = header.trim();
continue;
}
let Some((code, rest)) = line.split_once(char::is_whitespace) else {
continue;
};
if section == "layout" {
result.layouts.push(Choice {
code: code.into(),
name: rest.trim().into(),
translated: None,
});
} else if section == "variant"
&& let Some((layout, name)) = rest.trim().split_once(':')
{
result
.variants
.entry(layout.trim().into())
.or_default()
.push(Choice {
code: code.into(),
name: name.trim().into(),
translated: None,
});
} else if section == "option" && code.starts_with("grp:") {
result.switches.push(Choice {
code: code.into(),
name: rest.trim().into(),
translated: None,
});
}
}
result.layouts.sort_by_key(|c| c.name.to_lowercase());
result.layouts.dedup_by(|a, b| a.code == b.code);
result
}
}
pub(super) fn catalog() -> Result<Arc<Catalog>, String> {
static CACHE: OnceLock<Arc<Catalog>> = OnceLock::new();
if let Some(value) = CACHE.get() {
return Ok(value.clone());
}
let root = std::env::var_os("XKB_CONFIG_ROOT")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| "/usr/share/X11/xkb".into());
let mut source = String::new();
std::fs::File::open(root.join("rules/evdev.lst"))
.map_err(|e| e.to_string())?
.take(1024 * 1024 + 1)
.read_to_string(&mut source)
.map_err(|e| e.to_string())?;
if source.len() > 1024 * 1024 {
return Err("Keyboard catalog exceeds the size limit".into());
}
let mut parsed = Catalog::parse(&source);
if let Ok(file) = std::fs::File::open("/usr/share/locale/zh_CN/LC_MESSAGES/xkeyboard-config.mo")
{
let mut bytes = Vec::new();
if file
.take(4 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)
.is_ok()
&& bytes.len() <= 4 * 1024 * 1024
{
let translations = translations(&bytes);
for choice in parsed
.layouts
.iter_mut()
.chain(parsed.variants.values_mut().flatten())
.chain(parsed.switches.iter_mut())
{
choice.translated = translations.get(&choice.name).cloned();
}
}
}
if parsed.layouts.is_empty() {
return Err("No keyboard layouts found".into());
}
let value = Arc::new(parsed);
let _ = CACHE.set(value.clone());
Ok(value)
}
// Read the installed XKB gettext catalog without changing process-wide locale.
fn translations(bytes: &[u8]) -> BTreeMap<String, String> {
let mut result = BTreeMap::new();
let Some(magic) = bytes
.get(..4)
.and_then(|s| s.try_into().ok())
.map(u32::from_le_bytes)
else {
return result;
};
let big = match magic {
0x950412de => false,
0xde120495 => true,
_ => return result,
};
let word = |offset: usize| -> Option<usize> {
let raw: [u8; 4] = bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?;
Some(if big {
u32::from_be_bytes(raw)
} else {
u32::from_le_bytes(raw)
} as usize)
};
let Some((count, original, translated)) = word(8)
.zip(word(12))
.zip(word(16))
.map(|((a, b), c)| (a, b, c))
else {
return result;
};
if count > 100_000 {
return result;
}
let string = |table: usize, index: usize| -> Option<&str> {
let entry = table.checked_add(index.checked_mul(8)?)?;
let length = word(entry)?;
let start = word(entry.checked_add(4)?)?;
std::str::from_utf8(bytes.get(start..start.checked_add(length)?)?).ok()
};
for i in 0..count {
if let Some((key, value)) = string(original, i).zip(string(translated, i))
&& !key.is_empty()
&& !value.is_empty()
{
result.insert(
key.split('\0').next().unwrap_or_default().into(),
value.split('\0').next().unwrap_or_default().into(),
);
}
}
result
}
pub(super) struct LayoutRow {
layouts: combo_box::State<Choice>,
variants: combo_box::State<Choice>,
layout: Choice,
variant: Choice,
}
#[derive(Default)]
pub(super) struct State {
catalog: Option<Arc<Catalog>>,
loading: bool,
error: Option<String>,
rows: Vec<LayoutRow>,
signature: String,
pub adding: bool,
add_choices: Option<combo_box::State<Choice>>,
switches: Option<combo_box::State<Choice>>,
switch: Option<Choice>,
}
#[derive(Clone, Debug)]
pub(super) enum Input {
Layout(usize, Choice),
Variant(usize, Choice),
Switch(Choice),
Remove(usize),
Move(usize, bool),
Add,
Retry,
}
fn configured_layouts(snapshot: &Snapshot) -> Vec<(String, String)> {
let variants = snapshot.string("input.xkb_variant", "");
let variants: Vec<_> = variants.split(',').collect();
snapshot
.string("input.xkb_layout", "us")
.split(',')
.enumerate()
.map(|(i, l)| {
(
l.to_owned(),
variants.get(i).copied().unwrap_or_default().to_owned(),
)
})
.collect()
}
fn edits(rows: &[(String, String)]) -> Vec<Edit> {
vec![
set(
"input.xkb_layout",
rows.iter()
.map(|r| r.0.as_str())
.collect::<Vec<_>>()
.join(","),
),
set(
"input.xkb_variant",
rows.iter()
.map(|r| r.1.as_str())
.collect::<Vec<_>>()
.join(","),
),
]
}
impl State {
pub fn sync(&mut self, snapshot: &Snapshot) {
let signature = format!(
"{}|{}|{:?}|{}",
snapshot.string("input.xkb_layout", "us"),
snapshot.string("input.xkb_variant", ""),
ferese_config::locale::resolved(),
self.adding
);
let signature = format!("{signature}|{:?}", snapshot.item("input.xkb_options"));
if signature == self.signature {
return;
}
self.signature = signature;
let Some(catalog) = &self.catalog else { return };
let mut switches = vec![Choice {
code: String::new(),
name: "No layout switching shortcut".into(),
translated: None,
}];
switches.extend(catalog.switches.clone());
let configured = snapshot
.item("input.xkb_options")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
.find(|v| v.starts_with("grp:"))
.unwrap_or("");
let selected = switches
.iter()
.find(|s| s.code == configured)
.cloned()
.unwrap_or_else(|| Choice {
code: configured.into(),
name: configured.into(),
translated: None,
});
if !switches.contains(&selected) {
switches.push(selected.clone());
}
self.switch = Some(selected);
self.switches = Some(combo_box::State::new(switches));
self.rows = configured_layouts(snapshot)
.into_iter()
.map(|(layout, variant)| {
let mut layouts = catalog.layouts.clone();
let layout = layouts
.iter()
.find(|c| c.code == layout)
.cloned()
.unwrap_or_else(|| Choice {
code: layout.clone(),
name: layout,
translated: None,
});
if !layouts.contains(&layout) {
layouts.push(layout.clone());
}
let mut variants = vec![Choice {
code: String::new(),
name: "Default variant".into(),
translated: None,
}];
variants.extend(
catalog
.variants
.get(&layout.code)
.cloned()
.unwrap_or_default(),
);
let variant = variants
.iter()
.find(|v| v.code == variant)
.cloned()
.unwrap_or_else(|| Choice {
code: variant.clone(),
name: variant,
translated: None,
});
if !variants.contains(&variant) {
variants.push(variant.clone());
}
LayoutRow {
layouts: combo_box::State::new(layouts),
variants: combo_box::State::new(variants),
layout,
variant,
}
})
.collect();
}
}
impl App {
pub(super) fn load_keyboard_catalog(&mut self) -> Task<Message> {
if self.keyboard.catalog.is_some() || self.keyboard.loading {
return Task::none();
}
self.keyboard.loading = true;
cosmic::task::future(async {
Message::KeyboardCatalog(
tokio::task::spawn_blocking(catalog)
.await
.unwrap_or_else(|e| Err(e.to_string())),
)
})
}
pub(super) fn keyboard_catalog_loaded(&mut self, result: Result<Arc<Catalog>, String>) {
self.keyboard.loading = false;
match result {
Ok(value) => {
self.keyboard.catalog = Some(value);
self.keyboard.error = None;
self.keyboard.signature.clear();
self.keyboard.sync(&self.draft)
}
Err(error) => self.keyboard.error = Some(error),
}
}
pub(super) fn keyboard_input(&mut self, input: Input) -> Task<Message> {
match input {
Input::Retry => return self.load_keyboard_catalog(),
Input::Add => {
self.keyboard.adding = true;
self.keyboard.add_choices = self
.keyboard
.catalog
.as_ref()
.map(|c| combo_box::State::new(c.layouts.clone()));
return Task::none();
}
_ => {}
}
if let Input::Switch(choice) = input {
let mut options: Vec<_> = self
.draft
.item("input.xkb_options")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
.filter(|v| !v.starts_with("grp:"))
.map(str::to_owned)
.collect();
if !choice.code.is_empty() {
options.push(choice.code);
}
return self.change(set("input.xkb_options", options));
}
let mut rows = configured_layouts(&self.draft);
match input {
Input::Layout(index, choice) => {
if index == rows.len() {
rows.push((choice.code, String::new()));
self.keyboard.adding = false;
self.keyboard.add_choices = None;
} else if let Some(row) = rows.get_mut(index) {
row.0 = choice.code;
row.1.clear();
}
}
Input::Variant(index, choice) => {
if let Some(row) = rows.get_mut(index) {
row.1 = choice.code;
}
}
Input::Remove(index) if rows.len() > 1 && index < rows.len() => {
rows.remove(index);
}
Input::Move(index, up) if index < rows.len() => {
let next = if up {
index.saturating_sub(1)
} else {
(index + 1).min(rows.len() - 1)
};
rows.swap(index, next);
}
_ => return Task::none(),
}
let task = self.edit_many(edits(&rows));
self.keyboard.sync(&self.draft);
task
}
pub(super) fn keyboard_layout_controls(&self) -> Element<'_, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let mut items = column([])
.spacing(10)
.push(self.panel_heading(ferese_config::tr!("Keyboard layouts"), 14.));
if let Some(error) = &self.keyboard.error {
items = items.push(self.note(error)).push(self.settings_button(
"Retry",
"M20 7V3l-3 3a8 8 0 1 0 3 6",
Some(Message::Keyboard(Input::Retry)),
false,
));
} else if self.keyboard.catalog.is_none() {
items = items.push(self.note("Loading keyboard layouts…"));
}
for (i, entry) in self.keyboard.rows.iter().enumerate() {
items = items.push(
column([])
.spacing(6)
.push(
row([])
.spacing(8)
.align_y(cosmic::iced::Alignment::Center)
.push(self.search_select(
&entry.layouts,
ferese_config::tr!("Search layouts…"),
Some(&entry.layout),
move |v| Message::Keyboard(Input::Layout(i, v)),
))
.push(
self.settings_icon_button(
"Move up",
"M5 15l7-7 7 7",
(i > 0 && !self.saving)
.then_some(Message::Keyboard(Input::Move(i, true))),
),
)
.push(
self.settings_icon_button(
"Remove layout",
"M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13",
(self.keyboard.rows.len() > 1 && !self.saving)
.then_some(Message::Keyboard(Input::Remove(i))),
),
),
)
.push(self.search_select(
&entry.variants,
ferese_config::tr!("Search variants…"),
Some(&entry.variant),
move |v| Message::Keyboard(Input::Variant(i, v)),
)),
);
}
if let Some(options) = &self.keyboard.switches {
items = items
.push(self.label(ferese_config::tr!("Switch keyboard layout"), 12.))
.push(self.search_select(
options,
ferese_config::tr!("Search layout switching shortcuts…"),
self.keyboard.switch.as_ref(),
|v| Message::Keyboard(Input::Switch(v)),
));
}
if self.keyboard.adding
&& let Some(choices) = &self.keyboard.add_choices
{
let index = self.keyboard.rows.len();
items = items.push(self.search_select(
choices,
ferese_config::tr!("Select a layout to add…"),
None,
move |v| Message::Keyboard(Input::Layout(index, v)),
));
}
items = items.push(
row([])
.spacing(8)
.push(
self.settings_button(
"Add layout",
"M12 5v14 M5 12h14",
(!self.saving && self.keyboard.catalog.is_some())
.then_some(Message::Keyboard(Input::Add)),
false,
),
)
.push(self.settings_button(
"Shortcuts",
"M8 8h8v8H8z",
Some(Message::Page(crate::schema::Page::Shortcuts)),
false,
)),
);
container(items)
.padding(10)
.width(Length::Fill)
.class(visuals::section(p))
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::Application;
#[test]
fn switching_layouts_preserves_other_xkb_options_and_catalog_loads_coalesce() {
let (mut app, _) = App::init(
cosmic::app::Core::default(),
(
"/unused/keyboard-test.kdl".into(),
Snapshot::parse(
"input { xkb-options \"compose:ralt\" \"grp:alt_shift_toggle\"; }".into(),
),
None,
None,
),
);
assert_eq!(app.load_keyboard_catalog().units(), 1);
assert_eq!(app.load_keyboard_catalog().units(), 0);
app.keyboard_catalog_loaded(Ok(Arc::new(Catalog::parse(
"! layout\n us English (US)\n de German\n! option\n grp:ctrl_shift_toggle Ctrl+Shift\n",
))));
app.saving = true;
drop(app.keyboard_input(Input::Switch(Choice {
code: "grp:ctrl_shift_toggle".into(),
name: "Ctrl+Shift".into(),
translated: None,
})));
assert_eq!(
app.draft.item("input.xkb_options").unwrap(),
&serde_json::json!(["compose:ralt", "grp:ctrl_shift_toggle"])
);
assert_eq!(app.load_keyboard_catalog().units(), 0);
}
#[test]
fn gettext_reader_is_bounded_and_does_not_change_the_process_locale() {
let mut bytes = Vec::new();
for word in [0x950412de_u32, 0, 1, 28, 36, 0, 0, 6, 44, 6, 50] {
bytes.extend_from_slice(&word.to_le_bytes());
}
bytes.extend_from_slice("German德语".as_bytes());
assert_eq!(
translations(&bytes).get("German").map(String::as_str),
Some("德语")
);
for end in 0..bytes.len() {
let _ = translations(&bytes[..end]);
}
let language = ferese_config::locale::resolved();
let _ = translations(&bytes);
assert_eq!(language, ferese_config::locale::resolved());
}
#[test]
fn catalog_variants_stay_with_their_layout_and_roundtrip_aligned_lists() {
let c = Catalog::parse(
"! layout\n us English (US)\n de German\n! variant\n intl us: English (US, intl.)\n nodeadkeys de: German (no dead keys)\n! option\n ignored nope\n",
);
assert_eq!(c.layouts.len(), 2);
assert_eq!(c.variants["us"][0].code, "intl");
assert_eq!(c.variants["de"][0].code, "nodeadkeys");
let mut s =
Snapshot::parse("input { xkb-layout \"us,de\"; xkb-variant \",nodeadkeys\"; }".into())
.unwrap();
let mut rows = configured_layouts(&s);
rows.swap(0, 1);
for e in edits(&rows) {
s.edit(&e).unwrap();
}
assert_eq!(s.string("input.xkb_variant", ""), "nodeadkeys,");
let mut state = State {
catalog: Some(Arc::new(c)),
..Default::default()
};
state.sync(&s);
assert_eq!(state.rows[0].variant.code, "nodeadkeys");
}
}
+129 -34
View File
@@ -15,10 +15,15 @@ mod tray_controls;
use theme_controls::gallery_id;
mod display_controls;
mod display_map;
mod displays;
mod displays_ui;
mod fonts;
mod keyboard_controls;
mod schema;
mod selection;
mod shortcut_controls;
mod store;
mod visuals;
mod wallpaper_controls;
@@ -73,6 +78,12 @@ fn main() -> cosmic::iced::Result {
Some("general") => Some(InitialPage::Settings(Page::General)),
Some("calendar") => Some(InitialPage::Settings(Page::Calendar)),
Some("theme") => Some(InitialPage::Settings(Page::Theme)),
Some("keyboard") => Some(InitialPage::Settings(Page::Keyboard)),
Some("mouse") => Some(InitialPage::Settings(Page::Mouse)),
Some("touchpad") => Some(InitialPage::Settings(Page::Touchpad)),
Some("shortcuts") => Some(InitialPage::Settings(Page::Shortcuts)),
Some("displays") => Some(InitialPage::Settings(Page::Displays)),
Some("shell") => Some(InitialPage::Settings(Page::Shell)),
Some("tray") => Some(InitialPage::Settings(Page::Tray)),
Some("all-options") => Some(InitialPage::Settings(Page::Configuration)),
_ => {
@@ -118,6 +129,11 @@ enum InitialPage {
#[derive(Clone, Debug)]
enum Message {
DisplayInput(display_controls::Input),
DisplayCommandFinished(display_controls::Command, Result<(), String>),
Shortcut(shortcut_controls::Input),
Keyboard(keyboard_controls::Input),
KeyboardCatalog(Result<std::sync::Arc<keyboard_controls::Catalog>, String>),
Options(all_options::Input),
LoadOptions,
OptionsLoaded(Result<std::sync::Arc<serde_json::Value>, String>),
@@ -188,12 +204,14 @@ enum Message {
AddHoliday,
CalendarFunction(calendar_controls::FunctionInput),
AddCommand,
AddSwipe(&'static str),
Remove(String, usize),
Thumbnail(String, Result<widget::image::Handle, String>),
}
struct App {
display_state: display_controls::State,
shortcuts: shortcut_controls::State,
keyboard: keyboard_controls::State,
tray_apps: Vec<tray_controls::Application>,
tray_refresh: refresh::Gate,
options: all_options::State,
@@ -312,6 +330,9 @@ impl cosmic::Application for App {
ferese_config::locale::set(ferese_config::locale::from_document(&current.doc));
let options = all_options::State::pending(&current);
let mut app = Self {
display_state: Default::default(),
shortcuts: Default::default(),
keyboard: Default::default(),
tray_apps: Vec::new(),
tray_refresh: Default::default(),
options,
@@ -400,7 +421,7 @@ impl cosmic::Application for App {
Task::none()
};
let thumbnails = app.load_thumbnail();
let displays = if app.page == Page::Bar {
let displays = if matches!(app.page, Page::Bar | Page::Displays) {
app.load_displays()
} else {
Task::none()
@@ -410,9 +431,14 @@ impl cosmic::Application for App {
} else {
Task::none()
};
let keyboard = if app.page == Page::Keyboard {
app.load_keyboard_catalog()
} else {
Task::none()
};
(
app,
Task::batch([task, connections, thumbnails, displays, options]),
Task::batch([task, connections, thumbnails, displays, options, keyboard]),
)
}
@@ -422,6 +448,8 @@ impl cosmic::Application for App {
if matches!(
message,
Message::Draft(..)
| Message::Shortcut(_)
| Message::Keyboard(_)
| Message::Range(..)
| Message::Options(_)
| Message::CalendarFunction(_)
@@ -429,6 +457,13 @@ impl cosmic::Application for App {
self.edit_revision = self.edit_revision.wrapping_add(1);
}
match message {
Message::DisplayInput(input) => return self.display_input(input),
Message::DisplayCommandFinished(command, result) => {
return self.display_command_finished(command, result);
}
Message::Shortcut(input) => return self.shortcut_input(input),
Message::Keyboard(input) => return self.keyboard_input(input),
Message::KeyboardCatalog(result) => self.keyboard_catalog_loaded(result),
Message::TrayApps(apps) => {
self.tray_refresh.finish();
if let Ok(apps) = apps {
@@ -453,7 +488,10 @@ impl cosmic::Application for App {
Message::ConnectionsRefreshed(snapshot) => self.connections_refreshed(*snapshot),
Message::ConnectionFinished(result) => return self.connection_finished(result),
Message::ConnectionsLeft => {}
Message::SelectDisplay(key) => self.display_selection = Some(key),
Message::SelectDisplay(key) => {
self.inputs.remove("$display.profile_name");
self.display_selection = Some(key);
}
Message::DisplayResolution(prefix, size) => {
let matcher = self.draft.string(&format!("{prefix}.match"), "");
let configured = self.draft.string(&format!("{prefix}.mode"), "");
@@ -525,6 +563,7 @@ impl cosmic::Application for App {
|| !self.ranges.is_empty()
|| !self.pending.is_empty()
|| self.options.dirty()
|| self.shortcuts.opened
|| self.calendar_editor.dirty(&self.draft)
{
self.status =
@@ -539,6 +578,10 @@ impl cosmic::Application for App {
self.edit_revision = self.edit_revision.wrapping_add(1);
self.current = snapshot.clone();
self.draft = snapshot;
self.display_state.recovery = None;
self.display_state.queued = None;
self.keyboard.sync(&self.draft);
self.shortcuts.localize();
self.family_sections = theme_controls::FamilySections::new(
&self.resolved.families,
&self.draft,
@@ -601,9 +644,10 @@ impl cosmic::Application for App {
.map(cosmic::Action::App);
}
Message::Page(page) => {
let stop_recording = self.stop_shortcut_capture();
self.sidebar_open = false;
if self.page == page && self.search.is_empty() {
return Task::none();
return stop_recording;
}
self.page_transition = self.profile_pages.then(std::time::Instant::now);
@@ -624,6 +668,7 @@ impl cosmic::Application for App {
cosmic::iced::widget::scrollable::RelativeOffset::START.into(),
);
let reset = Task::batch([
stop_recording,
reset,
cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(
page,
@@ -635,6 +680,7 @@ impl cosmic::Application for App {
let load = match page {
Page::Displays | Page::Bar => self.load_displays(),
Page::Wallpaper => self.load_thumbnail(),
Page::Keyboard => self.load_keyboard_catalog(),
Page::Tray => self.load_tray_apps(),
Page::Configuration => self.load_options(),
_ => Task::none(),
@@ -653,6 +699,9 @@ impl cosmic::Application for App {
if page == Page::Wallpaper {
return Task::batch([reset, self.load_thumbnail()]);
}
if page == Page::Keyboard {
return Task::batch([reset, self.load_keyboard_catalog()]);
}
if page == Page::Configuration {
return Task::batch([reset, self.load_options()]);
}
@@ -864,6 +913,10 @@ impl cosmic::Application for App {
self.saving = false;
match result {
Ok((snapshot, live)) => {
if self.display_state.restoring {
self.display_state.restoring = false;
self.display_state.recovery = None;
}
self.undo = Some(std::mem::take(&mut self.saving_previous));
self.current = snapshot;
ferese_config::locale::set(ferese_config::locale::from_document(
@@ -876,6 +929,8 @@ impl cosmic::Application for App {
&self.resolved.presented.tokens.typography.font_family,
);
self.draft = self.current.clone();
self.keyboard.sync(&self.draft);
self.shortcuts.localize();
for edit in &self.pending {
if let Err(error) = self.draft.edit(edit) {
@@ -912,35 +967,57 @@ impl cosmic::Application for App {
)
})
.unwrap_or_else(Task::none);
let display_command = self
.display_state
.queued
.take()
.map(|command| self.display_command(command))
.unwrap_or_else(Task::none);
let display_refresh = if self.page == Page::Displays {
self.refresh_displays(true)
} else {
Task::none()
};
return Task::batch([
title,
self.update_theme(),
self.flush(),
self.load_thumbnail(),
expire,
display_command,
display_refresh,
]);
}
Err(error) => {
self.display_state.queued = None;
self.display_state.restoring = false;
self.error = Some(error);
self.pending.clear();
self.draft = self.current.clone();
self.keyboard.sync(&self.draft);
self.shortcuts.localize();
self.options.applying = false;
self.status = "Not saved".into();
}
}
}
Message::Reload if !self.saving && !self.reloading => {
self.shortcuts.opened = false;
let stop_recording = self.stop_shortcut_capture();
self.reloading = true;
let revision = self.edit_revision;
let path = self.path.clone();
return cosmic::task::future(async move {
Message::Reloaded(
revision,
tokio::task::spawn_blocking(move || Snapshot::read(&path))
.await
.unwrap_or_else(|e| Err(e.to_string())),
)
});
return Task::batch([
stop_recording,
cosmic::task::future(async move {
Message::Reloaded(
revision,
tokio::task::spawn_blocking(move || Snapshot::read(&path))
.await
.unwrap_or_else(|e| Err(e.to_string())),
)
}),
]);
}
Message::Reloaded(revision, result) => {
self.reloading = false;
@@ -956,6 +1033,10 @@ impl cosmic::Application for App {
self.calendar_editor.refresh(&snapshot);
self.current = snapshot.clone();
self.draft = snapshot;
self.display_state.recovery = None;
self.display_state.queued = None;
self.keyboard.sync(&self.draft);
self.shortcuts.localize();
self.family_sections = theme_controls::FamilySections::new(
&self.resolved.families,
&self.draft,
@@ -1185,24 +1266,6 @@ impl cosmic::Application for App {
self.error = Some("Enter a program and arguments with balanced quotes.".into())
}
},
Message::AddSwipe(keys) if !self.saving => {
let (action, argument) = match keys {
"Swipe3Up" => ("workspace-next", None),
"Swipe3Down" => ("workspace-previous", None),
"Swipe3Left" => ("focus", Some("right")),
_ => ("focus", Some("left")),
};
let mut fields = vec![
("keys".into(), keys.into()),
("action".into(), action.into()),
];
if let Some(argument) = argument {
fields.push(("argument".into(), argument.into()));
}
return self.change(Edit::Add("bindings".into(), fields));
}
Message::Remove(table, index) if !self.saving => {
self.inputs.clear();
let task = self.change(Edit::Remove(table, index));
@@ -1215,6 +1278,26 @@ impl cosmic::Application for App {
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::Subscription::batch([
if self.shortcuts.recording {
cosmic::iced::event::listen_with(|event, _, _| match event {
cosmic::iced::Event::Keyboard(event) => {
Some(Message::Shortcut(shortcut_controls::Input::Key(event)))
}
cosmic::iced::Event::Window(cosmic::iced::window::Event::Unfocused) => {
Some(Message::Shortcut(shortcut_controls::Input::Stop))
}
cosmic::iced::Event::PlatformSpecific(
cosmic::iced::event::PlatformSpecific::Wayland(
cosmic::iced::event::wayland::Event::ShortcutsInhibited(value),
),
) => Some(Message::Shortcut(shortcut_controls::Input::Inhibited(
value,
))),
_ => None,
})
} else {
cosmic::iced::Subscription::none()
},
cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
Some(Message::ReadRenderScale(id))
@@ -1292,6 +1375,9 @@ impl App {
cosmic::command::set_theme(palette.native_theme())
}
fn edit_many(&mut self, edits: Vec<Edit>) -> Task<Message> {
if edits.iter().any(display_controls::affects_outputs) {
self.remember_display_config();
}
self.edit_revision = self.edit_revision.wrapping_add(1);
for edit in edits {
if let Err(error) = self.draft.edit(&edit) {
@@ -1324,6 +1410,9 @@ impl App {
}
fn change(&mut self, edit: Edit) -> Task<Message> {
if display_controls::affects_outputs(&edit) {
self.remember_display_config();
}
self.edit_revision = self.edit_revision.wrapping_add(1);
if let Edit::Set(path, value) = &edit
&& matches!(
@@ -1375,6 +1464,8 @@ impl App {
}
fn flush(&mut self) -> Task<Message> {
self.keyboard.sync(&self.draft);
self.shortcuts.localize();
if self.saving || self.pending.is_empty() {
return Task::none();
}
@@ -1443,6 +1534,10 @@ mod tests {
focused: true,
current: None,
modes: vec![],
connected: true,
enabled: true,
scale: 1.,
..Default::default()
}]))),
);
assert_eq!(app.load_displays().units(), 0);
@@ -1951,9 +2046,9 @@ mod tests {
for (parent, query, destination) in [
(Page::Windows, "motion", Page::Motion),
(Page::Windows, "animation speed", Page::Motion),
(Page::Keyboard, "shortcuts", Page::Shortcuts),
(Page::Keyboard, "swipe actions", Page::Shortcuts),
(Page::Keyboard, "touchpad", Page::Keyboard),
(Page::Shortcuts, "shortcuts", Page::Shortcuts),
(Page::Shortcuts, "swipe actions", Page::Shortcuts),
(Page::Touchpad, "touchpad", Page::Touchpad),
] {
assert!(parent.matches(query));
assert_eq!(parent.search_destination(query), destination);
+44 -108
View File
@@ -307,6 +307,43 @@ impl App {
{
body = body.push(self.panel_controls());
}
if self.page == Page::Keyboard {
body = body.push(self.keyboard_layout_controls());
}
if self.page == Page::Touchpad {
body = body.push(self.settings_button(
"Customize gestures",
"M3 8h9a3 3 0 1 0-3-3",
Some(Message::Shortcut(
crate::shortcut_controls::Input::OpenGesture,
)),
false,
));
}
if self.page == Page::Shell {
body = body.push(
row([])
.spacing(8)
.push(self.settings_button(
"Tray icons",
"M3 5h18v14H3z",
Some(Message::Page(Page::Tray)),
false,
))
.push(self.settings_button(
"Calendar",
"M4 5h16v16H4z M4 10h16",
Some(Message::Page(Page::Calendar)),
false,
))
.push(self.settings_button(
"Notifications",
"M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z",
Some(Message::Page(Page::Notifications)),
false,
)),
);
}
let fields = if self.page == Page::Bar {
Vec::new()
} else if self.page == Page::Wallpaper {
@@ -318,10 +355,12 @@ impl App {
let fields = fields
.into_iter()
.filter(|field| {
self.page != Page::Theme
|| !(field.path == "theme.mode"
|| field.path.starts_with("theme.schedule.")
|| field.path.ends_with(".file"))
!(self.page == Page::Keyboard
&& (field.path == "input.xkb_layout" || field.path == "input.xkb_variant"))
&& (self.page != Page::Theme
|| !(field.path == "theme.mode"
|| field.path.starts_with("theme.schedule.")
|| field.path.ends_with(".file")))
})
.collect();
for group in schema::grouped_fields(self.page, fields) {
@@ -472,84 +511,7 @@ impl App {
),
);
}
Page::Shortcuts => {
body =
body.push(self.panel_heading(ferese_config::tr!("Custom shortcuts"), 14.));
body = body.push(self.note(ferese_config::tr!("Keyboard, mouse and swipe bindings share the same actions. Mouse shortcuts can use MouseLeft, MouseRight, MouseMiddle, MouseBack, MouseForward or WheelUp/Down/Left/Right with modifiers. Use pointer-move or pointer-resize for dragging. Swipes use Swipe3Up, Swipe3Down, Swipe3Left or Swipe3Right (also supports 4 or 5 fingers). Actions such as toggle-overview and none need no argument.")));
let mut gestures = row([]).spacing(6);
for (keys, label) in [
("Swipe3Up", "Customize swipe up"),
("Swipe3Down", "Customize swipe down"),
("Swipe3Left", "Customize swipe left"),
("Swipe3Right", "Customize swipe right"),
] {
let exists = (0..self.draft.records("bindings")).any(|index| {
self.draft
.string(&format!("bindings.{index}.keys"), "")
.eq_ignore_ascii_case(keys)
});
if !exists {
gestures = gestures.push(self.settings_icon_button(
label,
match keys {
"Swipe3Up" => "M12 20V4 M5 11l7-7 7 7",
"Swipe3Down" => "M12 4v16 M5 13l7 7 7-7",
"Swipe3Left" => "M20 12H4 M11 5l-7 7 7 7",
_ => "M4 12h16 M13 5l7 7-7 7",
},
(!self.saving).then_some(Message::AddSwipe(keys)),
));
}
}
body = body.push(gestures);
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 group = column([])
.spacing(1)
.push(self.field(schema::text(
format!("{prefix}.keys"),
"Shortcut",
"Keys or swipe, such as Super+Return",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.action"),
"Action",
"Action name, such as spawn or focus",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.argument"),
"Argument",
"Command, direction, or action argument",
"",
)));
container(group)
.padding(4)
.height(164)
.class(visuals::section(palette))
.into()
} else {
widget::Space::new().height(164).width(Length::Fill).into()
};
body = body.push(
cosmic::iced::widget::sensor(content)
.key(("bindings", index))
.anticipate(200)
.on_show(move |_| Message::RowVisibility("bindings", index, true))
.on_hide(Message::RowVisibility("bindings", index, false)),
);
}
if self.draft.records("bindings") == 0 {
body = body.push(self.note(ferese_config::tr!("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.")));
}
}
Page::Shortcuts => body = body.push(self.shortcut_controls()),
Page::Displays => body = body.push(self.displays_view()),
_ => {}
}
@@ -695,32 +657,6 @@ impl App {
));
let footer: Element<'_, Message> = if self.page == Page::Connections {
self.note(ferese_config::tr!("Connections are managed by the system. Passwords are not saved in your Ferese config."))
} else if self.page == Page::Bar {
container(
row([])
.spacing(12)
.align_y(Alignment::Center)
.push(self.note(if self.saving {
"Saving…"
} else {
&self.status
}))
.push(widget::Space::new().width(Length::Fill))
.push(self.settings_button(
ferese_config::tr!("Undo"),
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
(self.undo.is_some() && !self.saving).then_some(Message::Undo),
false,
))
.push(self.settings_button(
ferese_config::tr!("Reload configuration"),
"M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6",
(!self.saving).then_some(Message::Reload),
false,
)),
)
.padding([10, 0])
.into()
} else {
footer.into()
};
+12 -22
View File
@@ -113,7 +113,7 @@ impl App {
),
),
)
.padding(14)
.padding(10)
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 14.)),
);
@@ -150,12 +150,12 @@ impl App {
.into(),
),
)
.padding(16)
.padding(10)
.class(crate::visuals::surface(palette.card, 14.)),
);
let mut appearance = widget::column([])
.spacing(16)
.push(self.panel_heading(ferese_config::tr!("Appearance"), 15.))
.spacing(12)
.push(self.panel_heading(ferese_config::tr!("Panel appearance"), 14.))
.push(self.panel_style_choices(preset.unwrap_or_default()))
.push(self.panel_divider())
.push(self.panel_opacity_controls())
@@ -267,7 +267,7 @@ impl App {
rows = rows.push(
widget::container(appearance)
.id("panel-appearance-settings")
.padding(16)
.padding(10)
.width(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 14.)),
);
@@ -278,7 +278,7 @@ impl App {
let mut settings = widget::column([]).spacing(4).push(
widget::container(
widget::row![
self.panel_heading(ferese_config::tr!("Size & spacing"), 15.)
self.panel_heading(ferese_config::tr!("Size & spacing"), 14.)
.width(cosmic::iced::Length::Fill),
self.settings_button(
ferese_config::tr!("Reset to style"),
@@ -362,20 +362,6 @@ impl App {
.padding(12)
.class(crate::visuals::surface(palette.card, 14.)),
);
let mut shell = widget::column([])
.spacing(4)
.push(self.panel_heading(ferese_config::tr!("Shell appearance"), 15.));
for field in crate::schema::fields(Page::Bar)
.into_iter()
.filter(|field| field.path.starts_with("theme."))
{
shell = shell.push(self.field(field));
}
rows = rows.push(
widget::container(shell)
.padding(12)
.class(crate::visuals::surface(palette.card, 14.)),
);
}
PanelPage::Arrange => return self.panel_workspace(),
}
@@ -656,7 +642,7 @@ impl App {
cosmic::iced::widget::responsive(move |size| {
let editor = widget::column([])
.spacing(12)
.push(self.panel_heading(ferese_config::tr!("Arrangement"), 15.))
.push(self.panel_heading(ferese_config::tr!("Arrangement"), 14.))
.push(self.note(ferese_config::tr!(
"Drag items to reorder. Select an item or group to edit."
)))
@@ -736,7 +722,7 @@ impl App {
"M4 4h16v16H4z M14 4v16 M7 8h4 M7 12h4",
palette.muted,
))
.push(self.panel_heading(ferese_config::tr!("Make it your own"), 15.))
.push(self.panel_heading(ferese_config::tr!("Make it your own"), 14.))
.push(self.note(
ferese_config::tr!("Select an item to adjust how it appears, or a group to edit its placement and spacing."),
)),
@@ -1038,6 +1024,10 @@ mod tests {
focused: true,
current: None,
modes: Vec::new(),
connected: true,
enabled: true,
scale: 1.,
..Default::default()
}];
app.panel_preview_resolution.items.insert(
ferese_panel::ItemId("clock".into()),
+1 -1
View File
@@ -49,7 +49,7 @@ impl App {
row([])
.align_y(Alignment::Center)
.push(
self.label(ferese_config::tr!("Composition preview"), 14.)
self.panel_heading(ferese_config::tr!("Composition preview"), 14.)
.width(Length::Fill),
)
.push(self.note(ferese_config::tr!("Sample content"))),
@@ -19,6 +19,7 @@ fn fixture() -> App {
),
);
app.options_loaded(all_options::catalog());
app.keyboard_catalog_loaded(crate::keyboard_controls::catalog());
app.connections.snapshot = Some(connections::performance_snapshot());
app.tray_apps = (0..12)
.map(|i| tray_controls::Application {
@@ -32,6 +33,8 @@ fn fixture() -> App {
identity: format!("Test display {i}"),
profile: None,
logical_width: Some(1920),
logical_height: Some(1080),
position: Some([(i - 1) * 1920, 0]),
focused: i == 0,
current: Some(displays::Mode {
width: 3840,
@@ -50,6 +53,10 @@ fn fixture() -> App {
refresh: 60000,
},
],
connected: true,
enabled: true,
scale: 2.,
..Default::default()
})
.collect();
app
+79 -50
View File
@@ -18,6 +18,9 @@ pub enum Page {
Displays,
Connections,
Calendar,
Mouse,
Touchpad,
Shell,
}
impl Page {
@@ -29,7 +32,14 @@ impl Page {
),
(
"Interaction",
&[Self::Windows, Self::Keyboard, Self::Accessibility],
&[
Self::Windows,
Self::Keyboard,
Self::Mouse,
Self::Touchpad,
Self::Shortcuts,
Self::Accessibility,
],
),
(
"System",
@@ -49,7 +59,7 @@ impl Page {
pub fn navigation_page(self) -> Self {
match self {
Self::Motion => Self::Windows,
Self::Shortcuts => Self::Keyboard,
Self::Shell => Self::Bar,
_ => self,
}
}
@@ -57,10 +67,7 @@ impl Page {
pub fn subpages(self) -> &'static [(Self, &'static str)] {
match self.navigation_page() {
Self::Windows => &[(Self::Windows, "Windows"), (Self::Motion, "Motion")],
Self::Keyboard => &[
(Self::Keyboard, "Keyboard & mouse"),
(Self::Shortcuts, "Shortcuts"),
],
Self::Bar => &[(Self::Bar, "Panel"), (Self::Shell, "Shell")],
_ => &[],
}
}
@@ -77,7 +84,7 @@ impl Page {
}
#[cfg(test)]
pub const ALL: [Self; 18] = [
pub const ALL: [Self; 21] = [
Self::Appearance,
Self::General,
Self::Theme,
@@ -96,6 +103,9 @@ impl Page {
Self::Displays,
Self::Connections,
Self::Calendar,
Self::Mouse,
Self::Touchpad,
Self::Shell,
];
pub fn title(self) -> &'static str {
@@ -115,7 +125,10 @@ impl Page {
Self::LockScreen => "Lock screen",
Self::Windows => "Windows",
Self::Motion => "Motion",
Self::Keyboard => "Keyboard & mouse",
Self::Keyboard => "Keyboard",
Self::Mouse => "Mouse",
Self::Touchpad => "Touchpad",
Self::Shell => "Shell",
Self::Shortcuts => "Shortcuts",
Self::Startup => "Login items",
Self::Displays => "Displays",
@@ -140,7 +153,10 @@ impl Page {
Self::LockScreen => "Your desktop, safely put away.",
Self::Windows => "Window layouts, spacing and focus effects.",
Self::Motion => "Animations and transition speed.",
Self::Keyboard => "Keyboard layouts, pointer behavior and touchpad gestures.",
Self::Keyboard => "Keyboard layouts, variants and repeat behavior.",
Self::Mouse => "Mouse buttons, pointer movement and scrolling.",
Self::Touchpad => "Touchpad taps, scrolling and gestures.",
Self::Shell => "Shared surfaces, launcher and system menus.",
Self::Shortcuts => "Keyboard shortcuts and swipe actions.",
Self::Startup => "Ready when you sign in.",
Self::Displays => "A place for every screen.",
@@ -173,6 +189,9 @@ impl Page {
Self::Keyboard => {
"M3 6h18v12H3z M6 9h.01 M10 9h.01 M14 9h.01 M18 9h.01 M6 12h.01 M10 12h.01 M14 12h.01 M18 12h.01 M8 15h8"
}
Self::Mouse => "M8 10V7a4 4 0 0 1 8 0v10a4 4 0 0 1-8 0z M12 3v7 M8 10h8",
Self::Touchpad => "M3 5h18v14H3z M3 15h18 M12 15v4",
Self::Shell => "M3 4h18v16H3z M3 9h18 M8 9v11",
Self::Shortcuts => {
"M8 8h8v8H8z M8 8H5a3 3 0 1 1 3-3v3z M16 8V5a3 3 0 1 1 3 3h-3z M16 16h3a3 3 0 1 1-3 3v-3z M8 16v3a3 3 0 1 1-3-3h3z"
}
@@ -552,7 +571,7 @@ pub fn fields(page: Page) -> Vec<Field> {
},
),
],
Page::Bar => vec![
Page::Shell => vec![
range(
"theme.geometry.shell_radius",
"Shell corner radius",
@@ -586,6 +605,8 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: false,
},
),
],
Page::Bar => vec![
range(
"panels.0.geometry.height",
"Height",
@@ -867,43 +888,8 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: true,
},
),
toggle(
"input.touchpad.tap",
"Tap to click",
"Tap the touchpad to click. Updates connected devices immediately.",
true,
),
toggle(
"input.touchpad.natural_scroll",
"Natural scrolling",
"Move content in the direction of your fingers. Updates immediately.",
true,
),
toggle(
"input.touchpad.disable_while_typing",
"Ignore touchpad while typing",
"Avoid accidental pointer movement.",
true,
),
toggle(
"input.touchpad.disable_while_mouse_connected",
"Disable touchpad with a mouse",
"Pause the built-in touchpad while an external mouse is connected.",
false,
),
range(
"input.touchpad.accel_speed",
"Touchpad speed",
"Adjust pointer acceleration for the touchpad.",
RangeSpec {
default: 0.0,
min: -1.0,
max: 1.0,
step: 0.05,
suffix: "",
integer: false,
},
),
],
Page::Mouse => vec![
toggle(
"input.mouse.natural_scroll",
"Mouse natural scrolling",
@@ -948,6 +934,45 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: false,
},
),
],
Page::Touchpad => vec![
toggle(
"input.touchpad.tap",
"Tap to click",
"Tap the touchpad to click. Updates connected devices immediately.",
true,
),
toggle(
"input.touchpad.natural_scroll",
"Natural scrolling",
"Move content in the direction of your fingers. Updates immediately.",
true,
),
toggle(
"input.touchpad.disable_while_typing",
"Ignore touchpad while typing",
"Avoid accidental pointer movement.",
true,
),
toggle(
"input.touchpad.disable_while_mouse_connected",
"Disable touchpad with a mouse",
"Pause the built-in touchpad while an external mouse is connected.",
false,
),
range(
"input.touchpad.accel_speed",
"Touchpad speed",
"Adjust pointer acceleration for the touchpad.",
RangeSpec {
default: 0.0,
min: -1.0,
max: 1.0,
step: 0.05,
suffix: "",
integer: false,
},
),
range(
"input.touchpad.swipe_threshold",
"Swipe distance",
@@ -1055,6 +1080,9 @@ pub fn grouped_fields(page: Page, fields: Vec<Field>) -> Vec<FieldGroup> {
"Focus and window effects"
}
}
Page::Shell => "Shell surfaces",
Page::Mouse => "Mouse",
Page::Touchpad => "Touchpad",
Page::Keyboard => {
if p.starts_with("input.xkb_") {
"Keyboard layout"
@@ -1173,9 +1201,10 @@ mod group_tests {
actual.sort();
assert_eq!(actual, expected, "{page:?}");
}
let keyboard = grouped_fields(Page::Keyboard, fields(Page::Keyboard));
let mouse = grouped_fields(Page::Mouse, fields(Page::Mouse));
let touchpad = grouped_fields(Page::Touchpad, fields(Page::Touchpad));
assert!(
keyboard
mouse
.iter()
.find(|g| g.title == "Mouse")
.unwrap()
@@ -1184,7 +1213,7 @@ mod group_tests {
.all(|f| f.path.starts_with("input.mouse."))
);
assert!(
keyboard
touchpad
.iter()
.find(|g| g.title == "Touchpad")
.unwrap()
+13 -3
View File
@@ -1144,7 +1144,11 @@ fn appearance_cards_reflow_without_overlap_and_keep_every_destination() {
.operate(&mut tree, Layout::new(&node), &renderer, &mut cards);
assert_eq!(
cards.bounds.len(),
15,
crate::schema::Page::NAVIGATION
.iter()
.map(|(_, p)| p.len())
.sum::<usize>()
- 1,
"{width}: all settings destinations must be reachable"
);
for bounds in cards.bounds.values() {
@@ -1255,8 +1259,14 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() {
let mut centers = Centers::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut centers);
assert_eq!(centers.buttons.len(), 16);
assert_eq!(centers.parts.len(), 64);
assert_eq!(
centers.buttons.len(),
crate::schema::Page::NAVIGATION
.iter()
.map(|(_, p)| p.len())
.sum::<usize>()
);
assert_eq!(centers.parts.len(), centers.buttons.len() * 4);
let sidebar = centers.sidebar.unwrap();
assert!(
centers
+68
View File
@@ -0,0 +1,68 @@
//! Searchable choices share the settings palette and fit their filtered results.
use crate::{App, Element, Message, visuals};
use cosmic::iced::{Border, Length};
use cosmic::widget::combo_box;
impl App {
pub(super) fn search_select<'a, T: Clone + std::fmt::Display + 'static>(
&self,
state: &'a combo_box::State<T>,
placeholder: &'a str,
selected: Option<&T>,
select: impl Fn(T) -> Message + 'static,
) -> Element<'a, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let picker: cosmic::iced::widget::ComboBox<
'a,
T,
Message,
cosmic::iced::Theme,
cosmic::Renderer,
> = cosmic::iced::widget::combo_box(state, placeholder, selected, select);
let picker = picker
.font(self.font)
.size(12)
.padding([7, 10])
.width(Length::Fill)
.menu_height(Length::Shrink)
.input_style(move |_, status| cosmic::iced::widget::text_input::Style {
background: ferese_theme::mix(p.background, p.card, 0.4).into(),
border: Border {
width: 1.,
radius: 5.into(),
color: if matches!(
status,
cosmic::iced::widget::text_input::Status::Focused { .. }
) {
p.accent
} else {
ferese_theme::mix(p.card, p.text, 0.16)
},
..Default::default()
},
icon: p.muted,
placeholder: p.muted,
value: p.text,
selection: p.accent,
})
.menu_style(move |_| cosmic::iced::overlay::menu::Style {
background: p.card.into(),
border: Border {
width: 1.,
radius: 5.into(),
color: ferese_theme::mix(p.card, p.text, 0.2),
..Default::default()
},
text_color: p.text,
selected_text_color: p.text,
selected_background: ferese_theme::mix(p.card, p.accent, 0.2).into(),
shadow: Default::default(),
});
let theme = if p.background.r + p.background.g + p.background.b > 1.5 {
cosmic::iced::Theme::Light
} else {
cosmic::iced::Theme::Dark
};
cosmic::iced::widget::Themer::new(Some(theme), picker).into()
}
}
File diff suppressed because it is too large. Load diff