Add searchable input controls and display profile editing
This commit is contained in:
1 parent
78ce1ed590
commit
a2be421974
24 files changed
+3426
-249
No files matched your search
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(¤t.doc));
|
||||
let options = all_options::State::pending(¤t);
|
||||
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);
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user