fix clippy warnings

This commit is contained in:
Samuel Onoja committed 2026-10-01 13:24:21 +01:00
1 parent 7b88083020
commit bdc3ba435f
39 files changed
+825 -688

No files matched your search

+14 -28
View File
@@ -454,13 +454,9 @@ fn open_primary_device(
.connected_outputs = scan.connected_outputs; .connected_outputs = scan.connected_outputs;
let mut outputs = HashMap::new(); let mut outputs = HashMap::new();
for selection in scan.selections { for selection in scan.selections {
let OutputSelection { let crtc = selection.crtc;
connector, let mode = selection.mode;
crtc, let output = create_direct_output(state, &mut drm, &gbm, &renderer, selection)?;
mode,
settings,
} = selection;
let output = create_direct_output(state, &mut drm, &gbm, &renderer, connector, crtc, mode, &settings)?;
state state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -959,22 +955,9 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
} }
for (_, selection) in selections { for (_, selection) in selections {
let OutputSelection { let crtc = selection.crtc;
connector, let mode = selection.mode;
crtc, match create_direct_output(state, &mut device.drm, &device.gbm, &device.renderer, selection) {
mode,
settings,
} = selection;
match create_direct_output(
state,
&mut device.drm,
&device.gbm,
&device.renderer,
connector,
crtc,
mode,
&settings,
) {
Ok(output) => { Ok(output) => {
state state
.direct_backend .direct_backend
@@ -1419,11 +1402,14 @@ fn create_direct_output(
drm: &mut DrmDevice, drm: &mut DrmDevice,
gbm: &GbmDevice<DrmDeviceFd>, gbm: &GbmDevice<DrmDeviceFd>,
renderer: &GlesRenderer, renderer: &GlesRenderer,
connector: connector::Info, selection: OutputSelection,
crtc: crtc::Handle,
mode: DrmMode,
settings: &OutputSettings,
) -> Result<DirectOutput, Box<dyn Error>> { ) -> Result<DirectOutput, Box<dyn Error>> {
let OutputSelection {
connector,
crtc,
mode,
settings,
} = selection;
let identity = connector_identity(drm, &connector); let identity = connector_identity(drm, &connector);
let drm_surface = drm.create_surface(crtc, mode, &[connector.handle()])?; let drm_surface = drm.create_surface(crtc, mode, &[connector.handle()])?;
let allocator = GbmAllocator::new(gbm.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT); let allocator = GbmAllocator::new(gbm.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT);
@@ -1435,7 +1421,7 @@ fn create_direct_output(
)?; )?;
// Publish only after DRM/GBM creation succeeds; failed activation must not // Publish only after DRM/GBM creation succeeds; failed activation must not
// leave a phantom output/workspace that could receive evacuated windows. // leave a phantom output/workspace that could receive evacuated windows.
let (output, global) = create_output(state, &connector, mode, identity, settings); let (output, global) = create_output(state, &connector, mode, identity, &settings);
let damage_tracker = OutputDamageTracker::from_output(&output); let damage_tracker = OutputDamageTracker::from_output(&output);
let render_metrics = RenderMetrics::from_environment(output.name()); let render_metrics = RenderMetrics::from_environment(output.name());
+33 -33
View File
@@ -96,22 +96,22 @@ impl Config {
theme: &ferese_config::theme::ResolvedTheme, theme: &ferese_config::theme::ResolvedTheme,
inactive_dim: InactiveDimSettings, inactive_dim: InactiveDimSettings,
) -> Result<ThemeSettings, ConfigError> { ) -> Result<ThemeSettings, ConfigError> {
let mut config = Self::default(); let config = Self {
config.theme = serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()) theme: serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap())
.expect("resolved theme matches runtime schema"); .expect("resolved theme matches runtime schema"),
..Self::default()
};
let mut settings = config.theme_settings()?; let mut settings = config.theme_settings()?;
settings.inactive_dim = inactive_dim; settings.inactive_dim = inactive_dim;
Ok(settings) Ok(settings)
} }
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> { pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
self.notifications.validate().map_err(ConfigError::InvalidBinding)?; self.notifications.validate().map_err(ConfigError::Binding)?;
self.desktop_widgets.validate().map_err(ConfigError::InvalidBinding)?; self.desktop_widgets.validate().map_err(ConfigError::Binding)?;
for daemon in &self.autostart { for daemon in &self.autostart {
if daemon.command.first().is_none_or(|program| program.trim().is_empty()) { if daemon.command.first().is_none_or(|program| program.trim().is_empty()) {
return Err(ConfigError::InvalidBinding( return Err(ConfigError::Binding("autostart command must contain a program".into()));
"autostart command must contain a program".into(),
));
} }
} }
let input_settings = self.input_settings()?; let input_settings = self.input_settings()?;
@@ -217,29 +217,29 @@ pub enum ConfigError {
path: PathBuf, path: PathBuf,
source: ferese_config::Error, source: ferese_config::Error,
}, },
InvalidColumnWidth { ColumnWidth {
field: &'static str, field: &'static str,
value: String, value: String,
}, },
InvalidAnimationValue { AnimationValue {
field: &'static str, field: &'static str,
value: f64, value: f64,
}, },
InvalidLayoutValue { LayoutValue {
field: &'static str, field: &'static str,
value: f64, value: f64,
}, },
InvalidInputValue { InputValue {
field: &'static str, field: &'static str,
value: String, value: String,
}, },
InvalidBinding(String), Binding(String),
InvalidWindowRule(String), WindowRule(String),
InvalidThemeValue { ThemeValue {
field: &'static str, field: &'static str,
value: String, value: String,
}, },
InvalidOutputProfile(String), OutputProfile(String),
} }
impl fmt::Display for ConfigError { impl fmt::Display for ConfigError {
@@ -249,25 +249,25 @@ impl fmt::Display for ConfigError {
Self::Parse { path, source } => { Self::Parse { path, source } => {
write!(formatter, "failed to parse {}: {source}", path.display()) write!(formatter, "failed to parse {}: {source}", path.display())
} }
Self::InvalidColumnWidth { field, value } => write!( Self::ColumnWidth { field, value } => write!(
formatter, formatter,
"invalid scrolling.{field} {value}; expected a positive number or \"full\"" "invalid scrolling.{field} {value}; expected a positive number or \"full\""
), ),
Self::InvalidAnimationValue { field, value } => { Self::AnimationValue { field, value } => {
write!(formatter, "invalid animations.{field} value {value}") write!(formatter, "invalid animations.{field} value {value}")
} }
Self::InvalidLayoutValue { field, value } => { Self::LayoutValue { field, value } => {
write!(formatter, "invalid layout.{field} value {value}") write!(formatter, "invalid layout.{field} value {value}")
} }
Self::InvalidInputValue { field, value } => { Self::InputValue { field, value } => {
write!(formatter, "invalid input.{field} value {value}") write!(formatter, "invalid input.{field} value {value}")
} }
Self::InvalidBinding(message) => write!(formatter, "invalid binding: {message}"), Self::Binding(message) => write!(formatter, "invalid binding: {message}"),
Self::InvalidWindowRule(message) => write!(formatter, "invalid window rule: {message}"), Self::WindowRule(message) => write!(formatter, "invalid window rule: {message}"),
Self::InvalidThemeValue { field, value } => { Self::ThemeValue { field, value } => {
write!(formatter, "invalid theme.{field} value {value}") write!(formatter, "invalid theme.{field} value {value}")
} }
Self::InvalidOutputProfile(message) => { Self::OutputProfile(message) => {
write!(formatter, "invalid output profile: {message}") write!(formatter, "invalid output profile: {message}")
} }
} }
@@ -279,21 +279,21 @@ impl Error for ConfigError {
match self { match self {
#[cfg(test)] #[cfg(test)]
Self::Parse { source, .. } => Some(source), Self::Parse { source, .. } => Some(source),
Self::InvalidColumnWidth { .. } Self::ColumnWidth { .. }
| Self::InvalidAnimationValue { .. } | Self::AnimationValue { .. }
| Self::InvalidLayoutValue { .. } | Self::LayoutValue { .. }
| Self::InvalidInputValue { .. } | Self::InputValue { .. }
| Self::InvalidBinding(_) | Self::Binding(_)
| Self::InvalidWindowRule(_) | Self::WindowRule(_)
| Self::InvalidThemeValue { .. } | Self::ThemeValue { .. }
| Self::InvalidOutputProfile(_) => None, | Self::OutputProfile(_) => None,
} }
} }
} }
impl Config { impl Config {
pub fn window_rules(&self) -> Result<Vec<WindowRule>, ConfigError> { pub fn window_rules(&self) -> Result<Vec<WindowRule>, ConfigError> {
window_rules::validate(&self.window_rules).map_err(ConfigError::InvalidWindowRule) window_rules::validate(&self.window_rules).map_err(ConfigError::WindowRule)
} }
} }
+6 -6
View File
@@ -64,7 +64,7 @@ pub(super) fn nonnegative_theme_value(value: f64, field: &'static str) -> Result
if value.is_finite() && value >= 0.0 { if value.is_finite() && value >= 0.0 {
Ok(value) Ok(value)
} else { } else {
Err(ConfigError::InvalidThemeValue { Err(ConfigError::ThemeValue {
field, field,
value: value.to_string(), value: value.to_string(),
}) })
@@ -75,7 +75,7 @@ pub(super) fn finite_theme_value(value: f64, field: &'static str) -> Result<f64,
if value.is_finite() { if value.is_finite() {
Ok(value) Ok(value)
} else { } else {
Err(ConfigError::InvalidThemeValue { Err(ConfigError::ThemeValue {
field, field,
value: value.to_string(), value: value.to_string(),
}) })
@@ -86,7 +86,7 @@ pub(super) fn unit_theme_value(value: f64, field: &'static str) -> Result<f64, C
if value.is_finite() && (0.0..=1.0).contains(&value) { if value.is_finite() && (0.0..=1.0).contains(&value) {
Ok(value) Ok(value)
} else { } else {
Err(ConfigError::InvalidThemeValue { Err(ConfigError::ThemeValue {
field, field,
value: value.to_string(), value: value.to_string(),
}) })
@@ -96,7 +96,7 @@ pub(super) fn unit_theme_value(value: f64, field: &'static str) -> Result<f64, C
pub(super) fn parse_color(value: &str, field: &'static str) -> Result<RgbaColor, ConfigError> { pub(super) fn parse_color(value: &str, field: &'static str) -> Result<RgbaColor, ConfigError> {
let digits = value.strip_prefix('#').unwrap_or(value); let digits = value.strip_prefix('#').unwrap_or(value);
if !digits.is_ascii() || !matches!(digits.len(), 6 | 8) { if !digits.is_ascii() || !matches!(digits.len(), 6 | 8) {
return Err(ConfigError::InvalidThemeValue { return Err(ConfigError::ThemeValue {
field, field,
value: value.to_owned(), value: value.to_owned(),
}); });
@@ -108,13 +108,13 @@ pub(super) fn parse_color(value: &str, field: &'static str) -> Result<RgbaColor,
.zip(parse_channel(4)) .zip(parse_channel(4))
.map(|((red, green), blue)| (red, green, blue)) .map(|((red, green), blue)| (red, green, blue))
else { else {
return Err(ConfigError::InvalidThemeValue { return Err(ConfigError::ThemeValue {
field, field,
value: value.to_owned(), value: value.to_owned(),
}); });
}; };
let alpha = if digits.len() == 8 { let alpha = if digits.len() == 8 {
parse_channel(6).ok_or_else(|| ConfigError::InvalidThemeValue { parse_channel(6).ok_or_else(|| ConfigError::ThemeValue {
field, field,
value: value.to_owned(), value: value.to_owned(),
})? })?
+24 -29
View File
@@ -23,16 +23,13 @@ impl Config {
for configured in &self.bindings { for configured in &self.bindings {
let identity = binding_identity(configured, &keymap)?; let identity = binding_identity(configured, &keymap)?;
if !supplied.insert(identity.clone()) { if !supplied.insert(identity.clone()) {
return Err(ConfigError::InvalidBinding(format!( return Err(ConfigError::Binding(format!("duplicate binding {:?}", configured.keys)));
"duplicate binding {:?}",
configured.keys
)));
} }
bindings.retain(|binding| binding_identity_for_runtime(binding) != identity); bindings.retain(|binding| binding_identity_for_runtime(binding) != identity);
if configured.disabled { if configured.disabled {
if configured.action.is_some() || configured.argument.is_some() { if configured.action.is_some() || configured.argument.is_some() {
return Err(ConfigError::InvalidBinding(format!( return Err(ConfigError::Binding(format!(
"disabled binding {:?} cannot have an action or argument", "disabled binding {:?} cannot have an action or argument",
configured.keys configured.keys
))); )));
@@ -49,12 +46,10 @@ impl Config {
pub(super) fn validate_commands(commands: &HashMap<String, Vec<String>>) -> Result<(), ConfigError> { pub(super) fn validate_commands(commands: &HashMap<String, Vec<String>>) -> Result<(), ConfigError> {
for (name, argv) in commands { for (name, argv) in commands {
if name.trim().is_empty() { if name.trim().is_empty() {
return Err(ConfigError::InvalidBinding("command names cannot be empty".to_owned())); return Err(ConfigError::Binding("command names cannot be empty".to_owned()));
} }
if argv.is_empty() || argv[0].is_empty() { if argv.is_empty() || argv[0].is_empty() {
return Err(ConfigError::InvalidBinding(format!( return Err(ConfigError::Binding(format!("command {name:?} must contain a program")));
"command {name:?} must contain a program"
)));
} }
} }
@@ -74,7 +69,7 @@ pub(crate) fn physical_keymap(input: &InputSettings) -> Result<xkb::Keymap, Conf
options, options,
xkb::KEYMAP_COMPILE_NO_FLAGS, xkb::KEYMAP_COMPILE_NO_FLAGS,
) )
.ok_or_else(|| ConfigError::InvalidBinding("failed to compile the XKB keymap".to_owned())) .ok_or_else(|| ConfigError::Binding("failed to compile the XKB keymap".to_owned()))
} }
pub(super) fn parse_binding( pub(super) fn parse_binding(
@@ -83,7 +78,7 @@ pub(super) fn parse_binding(
keymap: &xkb::Keymap, keymap: &xkb::Keymap,
) -> Result<Binding, ConfigError> { ) -> Result<Binding, ConfigError> {
if configured.disabled { if configured.disabled {
return Err(ConfigError::InvalidBinding(format!( return Err(ConfigError::Binding(format!(
"disabled binding {:?} cannot be executed", "disabled binding {:?} cannot be executed",
configured.keys configured.keys
))); )));
@@ -93,7 +88,7 @@ pub(super) fn parse_binding(
let action = configured let action = configured
.action .action
.as_deref() .as_deref()
.ok_or_else(|| ConfigError::InvalidBinding(format!("binding {:?} has no action", configured.keys)))?; .ok_or_else(|| ConfigError::Binding(format!("binding {:?} has no action", configured.keys)))?;
let action = parse_action(action, configured.argument.as_deref(), commands)?; let action = parse_action(action, configured.argument.as_deref(), commands)?;
let trigger = match identity.match_mode { let trigger = match identity.match_mode {
BindingMatch::Keysym => BindingTrigger::Keysym(identity.key), BindingMatch::Keysym => BindingTrigger::Keysym(identity.key),
@@ -123,7 +118,7 @@ pub(super) fn binding_identity(
let (modifiers, key) = parse_chord(&configured.keys)?; let (modifiers, key) = parse_chord(&configured.keys)?;
if let Some((fingers, direction)) = parse_swipe(&key) { if let Some((fingers, direction)) = parse_swipe(&key) {
if configured.match_mode == BindingMatch::Physical || modifiers != BindingModifiers::default() { if configured.match_mode == BindingMatch::Physical || modifiers != BindingModifiers::default() {
return Err(ConfigError::InvalidBinding( return Err(ConfigError::Binding(
"swipes cannot use keyboard modifiers or physical key matching".into(), "swipes cannot use keyboard modifiers or physical key matching".into(),
)); ));
} }
@@ -139,13 +134,13 @@ pub(super) fn binding_identity(
.key_by_name(&key.to_ascii_uppercase()) .key_by_name(&key.to_ascii_uppercase())
.map(Keycode::raw) .map(Keycode::raw)
.ok_or_else(|| { .ok_or_else(|| {
ConfigError::InvalidBinding(format!( ConfigError::Binding(format!(
"unknown XKB physical key name {key:?} in {:?}", "unknown XKB physical key name {key:?} in {:?}",
configured.keys configured.keys
)) ))
})?, })?,
BindingMatch::Swipe => { BindingMatch::Swipe => {
return Err(ConfigError::InvalidBinding( return Err(ConfigError::Binding(
"gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(), "gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(),
)); ));
} }
@@ -178,7 +173,7 @@ pub(super) fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), Con
for component in chord.split('+').map(str::trim) { for component in chord.split('+').map(str::trim) {
if component.is_empty() { if component.is_empty() {
return Err(ConfigError::InvalidBinding(format!("invalid key chord {chord:?}"))); return Err(ConfigError::Binding(format!("invalid key chord {chord:?}")));
} }
let slot = match component.to_ascii_lowercase().as_str() { let slot = match component.to_ascii_lowercase().as_str() {
@@ -190,17 +185,17 @@ pub(super) fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), Con
}; };
if let Some(slot) = slot { if let Some(slot) = slot {
if *slot { if *slot {
return Err(ConfigError::InvalidBinding(format!("duplicate modifier in {chord:?}"))); return Err(ConfigError::Binding(format!("duplicate modifier in {chord:?}")));
} }
*slot = true; *slot = true;
} else if key.replace(component.to_owned()).is_some() { } else if key.replace(component.to_owned()).is_some() {
return Err(ConfigError::InvalidBinding(format!( return Err(ConfigError::Binding(format!(
"key chord {chord:?} contains more than one key" "key chord {chord:?} contains more than one key"
))); )));
} }
} }
let key = key.ok_or_else(|| ConfigError::InvalidBinding(format!("key chord {chord:?} does not contain a key")))?; let key = key.ok_or_else(|| ConfigError::Binding(format!("key chord {chord:?} does not contain a key")))?;
Ok((modifiers, key)) Ok((modifiers, key))
} }
@@ -236,7 +231,7 @@ pub(super) fn parse_keysym(name: &str) -> Result<u32, ConfigError> {
symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_CASE_INSENSITIVE); symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_CASE_INSENSITIVE);
} }
if symbol.raw() == keysyms::KEY_NoSymbol { if symbol.raw() == keysyms::KEY_NoSymbol {
Err(ConfigError::InvalidBinding(format!("unknown keysym {name:?}"))) Err(ConfigError::Binding(format!("unknown keysym {name:?}")))
} else { } else {
Ok(symbol.raw()) Ok(symbol.raw())
} }
@@ -249,7 +244,7 @@ pub(super) fn parse_action(
) -> Result<BindingAction, ConfigError> { ) -> Result<BindingAction, ConfigError> {
let no_argument = || { let no_argument = || {
if argument.is_some() { if argument.is_some() {
Err(ConfigError::InvalidBinding(format!( Err(ConfigError::Binding(format!(
"action {action:?} does not accept an argument" "action {action:?} does not accept an argument"
))) )))
} else { } else {
@@ -257,7 +252,7 @@ pub(super) fn parse_action(
} }
}; };
let required_argument = let required_argument =
|| argument.ok_or_else(|| ConfigError::InvalidBinding(format!("action {action:?} requires an argument"))); || argument.ok_or_else(|| ConfigError::Binding(format!("action {action:?} requires an argument")));
match action { match action {
"none" => { "none" => {
@@ -276,7 +271,7 @@ pub(super) fn parse_action(
let command = required_argument()?; let command = required_argument()?;
let argv = commands let argv = commands
.get(command) .get(command)
.ok_or_else(|| ConfigError::InvalidBinding(format!("unknown command {command:?}")))?; .ok_or_else(|| ConfigError::Binding(format!("unknown command {command:?}")))?;
Ok(BindingAction::Spawn(argv.clone())) Ok(BindingAction::Spawn(argv.clone()))
} }
"close" => { "close" => {
@@ -332,7 +327,7 @@ pub(super) fn parse_action(
no_argument()?; no_argument()?;
Ok(BindingAction::ToggleKeybindingGuide) Ok(BindingAction::ToggleKeybindingGuide)
} }
_ => Err(ConfigError::InvalidBinding(format!("unknown action {action:?}"))), _ => Err(ConfigError::Binding(format!("unknown action {action:?}"))),
} }
} }
@@ -342,14 +337,14 @@ pub(super) fn parse_direction(argument: &str) -> Result<Direction, ConfigError>
"right" => Ok(Direction::Right), "right" => Ok(Direction::Right),
"up" => Ok(Direction::Up), "up" => Ok(Direction::Up),
"down" => Ok(Direction::Down), "down" => Ok(Direction::Down),
_ => Err(ConfigError::InvalidBinding(format!("invalid direction {argument:?}"))), _ => Err(ConfigError::Binding(format!("invalid direction {argument:?}"))),
} }
} }
pub(super) fn parse_workspace(argument: &str) -> Result<u8, ConfigError> { pub(super) fn parse_workspace(argument: &str) -> Result<u8, ConfigError> {
match argument.parse() { match argument.parse() {
Ok(workspace) if workspace > 0 => Ok(workspace), Ok(workspace) if workspace > 0 => Ok(workspace),
_ => Err(ConfigError::InvalidBinding(format!("invalid workspace {argument:?}"))), _ => Err(ConfigError::Binding(format!("invalid workspace {argument:?}"))),
} }
} }
@@ -459,7 +454,7 @@ pub enum BindingAction {
impl Binding { impl Binding {
pub(crate) fn portal_trigger(chord: &str) -> Result<Self, ConfigError> { pub(crate) fn portal_trigger(chord: &str) -> Result<Self, ConfigError> {
if chord.len() > 256 { if chord.len() > 256 {
return Err(ConfigError::InvalidBinding("Shortcut is too long".into())); return Err(ConfigError::Binding("Shortcut is too long".into()));
} }
let parts = chord.split('+').collect::<Vec<_>>(); let parts = chord.split('+').collect::<Vec<_>>();
if parts if parts
@@ -468,7 +463,7 @@ impl Binding {
.count() .count()
> 1 > 1
{ {
return Err(ConfigError::InvalidBinding("Duplicate NUM modifier".into())); return Err(ConfigError::Binding("Duplicate NUM modifier".into()));
} }
let chord = parts let chord = parts
.into_iter() .into_iter()
@@ -482,7 +477,7 @@ impl Binding {
&& matches!(symbol, keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12)) && matches!(symbol, keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12))
|| (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol) || (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol)
{ {
return Err(ConfigError::InvalidBinding("Reserved system shortcut".into())); return Err(ConfigError::Binding("Reserved system shortcut".into()));
} }
Ok(Self { Ok(Self {
modifiers, modifiers,
+6 -6
View File
@@ -3,26 +3,26 @@ use super::*;
impl Config { impl Config {
pub fn input_settings(&self) -> Result<InputSettings, ConfigError> { pub fn input_settings(&self) -> Result<InputSettings, ConfigError> {
if self.input.xkb_layout.trim().is_empty() { if self.input.xkb_layout.trim().is_empty() {
return Err(ConfigError::InvalidInputValue { return Err(ConfigError::InputValue {
field: "xkb_layout", field: "xkb_layout",
value: self.input.xkb_layout.clone(), value: self.input.xkb_layout.clone(),
}); });
} }
if self.input.repeat_rate <= 0 { if self.input.repeat_rate <= 0 {
return Err(ConfigError::InvalidInputValue { return Err(ConfigError::InputValue {
field: "repeat_rate", field: "repeat_rate",
value: self.input.repeat_rate.to_string(), value: self.input.repeat_rate.to_string(),
}); });
} }
if self.input.repeat_delay_ms < 0 { if self.input.repeat_delay_ms < 0 {
return Err(ConfigError::InvalidInputValue { return Err(ConfigError::InputValue {
field: "repeat_delay_ms", field: "repeat_delay_ms",
value: self.input.repeat_delay_ms.to_string(), value: self.input.repeat_delay_ms.to_string(),
}); });
} }
if !(16..=1000).contains(&self.input.touchpad.swipe_threshold) { if !(16..=1000).contains(&self.input.touchpad.swipe_threshold) {
return Err(ConfigError::InvalidInputValue { return Err(ConfigError::InputValue {
field: "touchpad.swipe_threshold", field: "touchpad.swipe_threshold",
value: self.input.touchpad.swipe_threshold.to_string(), value: self.input.touchpad.swipe_threshold.to_string(),
}); });
@@ -56,7 +56,7 @@ impl Config {
let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?; let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?;
let damping = self.animations.spring.damping; let damping = self.animations.spring.damping;
if !damping.is_finite() || damping < 0.0 { if !damping.is_finite() || damping < 0.0 {
return Err(ConfigError::InvalidAnimationValue { return Err(ConfigError::AnimationValue {
field: "spring.damping", field: "spring.damping",
value: damping, value: damping,
}); });
@@ -92,7 +92,7 @@ pub(super) fn positive_animation_value(value: f64, field: &'static str) -> Resul
if value.is_finite() && value > 0.0 { if value.is_finite() && value > 0.0 {
Ok(value) Ok(value)
} else { } else {
Err(ConfigError::InvalidAnimationValue { field, value }) Err(ConfigError::AnimationValue { field, value })
} }
} }
+2 -2
View File
@@ -59,7 +59,7 @@ pub(super) fn nonnegative_layout_value(value: f64, field: &'static str) -> Resul
if value.is_finite() && value >= 0.0 { if value.is_finite() && value >= 0.0 {
Ok(value) Ok(value)
} else { } else {
Err(ConfigError::InvalidLayoutValue { field, value }) Err(ConfigError::LayoutValue { field, value })
} }
} }
@@ -67,7 +67,7 @@ pub(super) fn parse_column_width(value: &ColumnWidthValue, field: &'static str)
match value { match value {
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => Ok(ColumnWidth::Proportion(*value)), ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => Ok(ColumnWidth::Proportion(*value)),
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => Ok(ColumnWidth::Full), ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => Ok(ColumnWidth::Full),
value => Err(ConfigError::InvalidColumnWidth { value => Err(ConfigError::ColumnWidth {
field, field,
value: match value { value: match value {
ColumnWidthValue::Proportion(value) => value.to_string(), ColumnWidthValue::Proportion(value) => value.to_string(),
+7 -13
View File
@@ -9,19 +9,13 @@ impl Config {
.map(|profile| { .map(|profile| {
let name = profile.name.trim(); let name = profile.name.trim();
if name.is_empty() { if name.is_empty() {
return Err(ConfigError::InvalidOutputProfile( return Err(ConfigError::OutputProfile("profile names cannot be empty".to_owned()));
"profile names cannot be empty".to_owned(),
));
} }
if !names.insert(name.to_owned()) { if !names.insert(name.to_owned()) {
return Err(ConfigError::InvalidOutputProfile(format!( return Err(ConfigError::OutputProfile(format!("duplicate profile name {name:?}")));
"duplicate profile name {name:?}"
)));
} }
if profile.outputs.is_empty() { if profile.outputs.is_empty() {
return Err(ConfigError::InvalidOutputProfile(format!( return Err(ConfigError::OutputProfile(format!("profile {name:?} has no outputs")));
"profile {name:?} has no outputs"
)));
} }
let mut matchers = HashSet::new(); let mut matchers = HashSet::new();
@@ -31,17 +25,17 @@ impl Config {
.map(|output| { .map(|output| {
let matcher = output.matcher.trim(); let matcher = output.matcher.trim();
if matcher.is_empty() { if matcher.is_empty() {
return Err(ConfigError::InvalidOutputProfile(format!( return Err(ConfigError::OutputProfile(format!(
"profile {name:?} contains an empty output matcher" "profile {name:?} contains an empty output matcher"
))); )));
} }
if !matchers.insert(matcher.to_owned()) { if !matchers.insert(matcher.to_owned()) {
return Err(ConfigError::InvalidOutputProfile(format!( return Err(ConfigError::OutputProfile(format!(
"profile {name:?} repeats output matcher {matcher:?}" "profile {name:?} repeats output matcher {matcher:?}"
))); )));
} }
if !output.scale.is_finite() || output.scale <= 0.0 { if !output.scale.is_finite() || output.scale <= 0.0 {
return Err(ConfigError::InvalidOutputProfile(format!( return Err(ConfigError::OutputProfile(format!(
"profile {name:?} output {matcher:?} has invalid scale {}", "profile {name:?} output {matcher:?} has invalid scale {}",
output.scale output.scale
))); )));
@@ -56,7 +50,7 @@ impl Config {
.as_deref() .as_deref()
.map(parse_output_mode) .map(parse_output_mode)
.transpose() .transpose()
.map_err(ConfigError::InvalidOutputProfile)?, .map_err(ConfigError::OutputProfile)?,
scale: output.scale, scale: output.scale,
transform: output.transform, transform: output.transform,
position: output.position, position: output.position,
+106 -103
View File
@@ -215,10 +215,13 @@ impl InputCapture {
} }
for barrier in barriers { for barrier in barriers {
let kind = edge(&barrier, &self.zones); let kind = edge(&barrier, &self.zones);
if zone_set != self.zone_set || counts[&barrier.id] != 1 || kind.is_none() { if zone_set == self.zone_set
failed.push(barrier.id); && counts[&barrier.id] == 1
&& let Some(kind) = kind
{
accepted.push((barrier, kind));
} else { } else {
accepted.push((barrier, kind.unwrap())); failed.push(barrier.id);
} }
} }
self.sessions.get_mut(&id).unwrap().barriers = accepted; self.sessions.get_mut(&id).unwrap().barriers = accepted;
@@ -418,6 +421,106 @@ impl InputCapture {
} }
} }
impl crate::Ferese {
pub(crate) fn refresh_input_capture_zones(&mut self) {
if !self.input_capture.has_sessions() {
return;
}
let active = self.input_capture.active();
let zones = self
.space
.outputs()
.filter_map(|output| {
let rect = self.space.output_geometry(output)?;
Some(Zone {
width: u32::try_from(rect.size.w).ok()?,
height: u32::try_from(rect.size.h).ok()?,
x: rect.loc.x,
y: rect.loc.y,
})
})
.collect();
self.input_capture.update_zones(zones);
if active && !self.input_capture.active() {
self.restore_input_capture_focus();
}
}
pub(crate) fn restore_input_capture_focus(&mut self) {
self.input_capture.restore_focus = false;
self.cursor_redraw_pending = true;
if !self.session_lock.active {
self.restore_keyboard_focus();
if let Some(pointer) = self.seat.get_pointer() {
let location = pointer.current_location();
pointer.motion(
self,
self.surface_under(location),
&smithay::input::pointer::MotionEvent {
location,
serial: smithay::utils::SERIAL_COUNTER.next_serial(),
time: 0,
},
);
pointer.frame(self);
}
}
}
pub(crate) fn capture_pointer_motion(
&mut self,
from: smithay::utils::Point<f64, smithay::utils::Logical>,
to: smithay::utils::Point<f64, smithay::utils::Logical>,
absolute: bool,
) -> bool {
let pointer = self.seat.get_pointer().expect("seat has pointer");
let active = self.input_capture.active();
let allowed = !self.session_lock.active
&& !self.overview.is_presenting()
&& !pointer.is_grabbed()
&& !self.swipe.active()
&& self.seat.get_keyboard().is_none_or(|keyboard| !keyboard.is_grabbed())
&& self.seat.get_touch().is_none_or(|touch| !touch.is_grabbed())
&& !self.seat.keyboard_shortcuts_inhibited();
if !self.input_capture.motion((from.x, from.y), (to.x, to.y), allowed) {
return false;
}
let delta = if absolute {
self.input_capture.absolute_delta((to.x, to.y))
} else {
(to.x - from.x, to.y - from.y)
};
if !active {
self.cursor_redraw_pending = true;
pointer.motion(
self,
None,
&smithay::input::pointer::MotionEvent {
location: from,
serial: smithay::utils::SERIAL_COUNTER.next_serial(),
time: 0,
},
);
pointer.frame(self);
if self.input_capture.captures(1) {
let keyboard = self.seat.get_keyboard().expect("seat has keyboard");
let keys = keyboard
.pressed_keys()
.iter()
.filter(|key| !self.intercepted_keys.contains(key))
.map(|key| key.raw().saturating_sub(8))
.collect::<Vec<_>>();
keyboard.set_focus(self, None, smithay::utils::SERIAL_COUNTER.next_serial());
self.input_capture.push(json!({"type":"keys", "keys":keys}));
}
} else if self.input_capture.captures(2) {
self.input_capture
.push(json!({"type":"motion", "x":delta.0, "y":delta.1}));
}
true
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -556,103 +659,3 @@ mod tests {
assert!(capture.sessions[&id].barriers.is_empty()); assert!(capture.sessions[&id].barriers.is_empty());
} }
} }
impl crate::Ferese {
pub(crate) fn refresh_input_capture_zones(&mut self) {
if !self.input_capture.has_sessions() {
return;
}
let active = self.input_capture.active();
let zones = self
.space
.outputs()
.filter_map(|output| {
let rect = self.space.output_geometry(output)?;
Some(Zone {
width: u32::try_from(rect.size.w).ok()?,
height: u32::try_from(rect.size.h).ok()?,
x: rect.loc.x,
y: rect.loc.y,
})
})
.collect();
self.input_capture.update_zones(zones);
if active && !self.input_capture.active() {
self.restore_input_capture_focus();
}
}
pub(crate) fn restore_input_capture_focus(&mut self) {
self.input_capture.restore_focus = false;
self.cursor_redraw_pending = true;
if !self.session_lock.active {
self.restore_keyboard_focus();
if let Some(pointer) = self.seat.get_pointer() {
let location = pointer.current_location();
pointer.motion(
self,
self.surface_under(location),
&smithay::input::pointer::MotionEvent {
location,
serial: smithay::utils::SERIAL_COUNTER.next_serial(),
time: 0,
},
);
pointer.frame(self);
}
}
}
pub(crate) fn capture_pointer_motion(
&mut self,
from: smithay::utils::Point<f64, smithay::utils::Logical>,
to: smithay::utils::Point<f64, smithay::utils::Logical>,
absolute: bool,
) -> bool {
let pointer = self.seat.get_pointer().expect("seat has pointer");
let active = self.input_capture.active();
let allowed = !self.session_lock.active
&& !self.overview.is_presenting()
&& !pointer.is_grabbed()
&& !self.swipe.active()
&& self.seat.get_keyboard().is_none_or(|keyboard| !keyboard.is_grabbed())
&& self.seat.get_touch().is_none_or(|touch| !touch.is_grabbed())
&& !self.seat.keyboard_shortcuts_inhibited();
if !self.input_capture.motion((from.x, from.y), (to.x, to.y), allowed) {
return false;
}
let delta = if absolute {
self.input_capture.absolute_delta((to.x, to.y))
} else {
(to.x - from.x, to.y - from.y)
};
if !active {
self.cursor_redraw_pending = true;
pointer.motion(
self,
None,
&smithay::input::pointer::MotionEvent {
location: from,
serial: smithay::utils::SERIAL_COUNTER.next_serial(),
time: 0,
},
);
pointer.frame(self);
if self.input_capture.captures(1) {
let keyboard = self.seat.get_keyboard().expect("seat has keyboard");
let keys = keyboard
.pressed_keys()
.iter()
.filter(|key| !self.intercepted_keys.contains(key))
.map(|key| key.raw().saturating_sub(8))
.collect::<Vec<_>>();
keyboard.set_focus(self, None, smithay::utils::SERIAL_COUNTER.next_serial());
self.input_capture.push(json!({"type":"keys", "keys":keys}));
}
} else if self.input_capture.captures(2) {
self.input_capture
.push(json!({"type":"motion", "x":delta.0, "y":delta.1}));
}
true
}
}
+4 -5
View File
@@ -104,11 +104,10 @@ pub(crate) fn capture_window_frame(
pixel.swap(0, 2); pixel.swap(0, 2);
let alpha = pixel[3] as u16; let alpha = pixel[3] as u16;
for channel in &mut pixel[..3] { for channel in &mut pixel[..3] {
*channel = if alpha == 0 { *channel = (*channel as u16 * 255 + alpha / 2)
0 .checked_div(alpha)
} else { .unwrap_or(0)
((*channel as u16 * 255 + alpha / 2) / alpha).min(255) as u8 .min(255) as u8;
};
} }
} }
Ok(crate::handlers::screenshot::CaptureBuffer { Ok(crate::handlers::screenshot::CaptureBuffer {
+29 -6
View File
@@ -37,7 +37,19 @@ pub(super) fn append_material_surface(
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(index, (rect, radius))| { .filter_map(|(index, (rect, radius))| {
material_element(state, renderer, output, surface, *rect, *radius, index, geometry, 1.0) material_element(
state,
renderer,
output,
surface,
MaterialSurface {
geometry: *rect,
radius: *radius,
index,
capture_geometry: geometry,
alpha: 1.0,
},
)
}) })
.collect(); .collect();
if let Some(buffers) = state.material_buffers.get_mut(surface) { if let Some(buffers) = state.material_buffers.get_mut(surface) {
@@ -89,17 +101,28 @@ pub(super) fn append_material_surface(
} }
} }
pub(super) struct MaterialSurface {
pub geometry: Rectangle<i32, Logical>,
pub radius: f32,
pub index: usize,
pub capture_geometry: Rectangle<i32, Logical>,
pub alpha: f32,
}
pub(super) fn material_element( pub(super) fn material_element(
state: &mut Ferese, state: &mut Ferese,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
output: &Output, output: &Output,
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
geometry: Rectangle<i32, Logical>, surface_geometry: MaterialSurface,
radius: f32,
index: usize,
capture_geometry: Rectangle<i32, Logical>,
alpha: f32,
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { ) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
let MaterialSurface {
geometry,
radius,
index,
capture_geometry,
alpha,
} = surface_geometry;
let (role, generation) = crate::effects::surface_role(surface)?; let (role, generation) = crate::effects::surface_role(surface)?;
let material = crate::effects::resolve_material( let material = crate::effects::resolve_material(
role, role,
+1 -1
View File
@@ -527,7 +527,7 @@ mod tests {
.unwrap(); .unwrap();
let size = (32, 32).into(); let size = (32, 32).into();
let mut pixels = vec![128u8; 32 * 32 * 4]; let mut pixels = vec![128u8; 32 * 32 * 4];
for pixel in pixels.chunks_exact_mut(4) { for pixel in pixels.as_chunks_mut::<4>().0 {
pixel[3] = 255; pixel[3] = 255;
} }
let blurred = renderer.import_memory(&pixels, Fourcc::Abgr8888, size, false).unwrap(); let blurred = renderer.import_memory(&pixels, Fourcc::Abgr8888, size, false).unwrap();
+7 -5
View File
@@ -306,11 +306,13 @@ pub(crate) fn output_elements(
renderer, renderer,
output, output,
surface, surface,
constrain, MaterialSurface {
window_radius as f32, geometry: constrain,
0, radius: window_radius as f32,
constrain, index: 0,
close_alpha, capture_geometry: constrain,
alpha: close_alpha,
},
) )
{ {
elements.push(background); elements.push(background);
+1 -1
View File
@@ -36,7 +36,7 @@ impl Default for IdleSettings {
impl IdleSettings { impl IdleSettings {
pub(crate) fn validate(self) -> Result<Self, crate::config::ConfigError> { pub(crate) fn validate(self) -> Result<Self, crate::config::ConfigError> {
if self.dim_after_seconds > 86400 || self.sleep_after_seconds > 86400 { if self.dim_after_seconds > 86400 || self.sleep_after_seconds > 86400 {
return Err(crate::config::ConfigError::InvalidInputValue { return Err(crate::config::ConfigError::InputValue {
field: "lock_screen.idle_delay", field: "lock_screen.idle_delay",
value: "must be between 0 and 86400 seconds".into(), value: "must be between 0 and 86400 seconds".into(),
}); });
+47 -48
View File
@@ -1248,6 +1248,53 @@ impl ResolvedTheme {
} }
} }
pub type ImportedFamily = (crate::families::Family, (Option<Tokens>, Option<Tokens>), Vec<String>);
pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
let document = Document::parse(source).map_err(|e| e.to_string())?;
let root = document.get("theme").unwrap_or(document.value());
let name = root.get("name").and_then(Value::as_str).unwrap_or(id).to_owned();
let mut variants = [None, None];
let mut warnings = vec![];
for (index, (key, appearance)) in [("light", Appearance::Light), ("dark", Appearance::Dark)]
.into_iter()
.enumerate()
{
if let Some(layer) = root.get(key) {
let base = preset(
if appearance == Appearance::Light {
"ferese-blue-light"
} else {
"ferese-blue"
},
appearance,
)?;
let mut value = serde_json::to_value(base).map_err(|e| e.to_string())?;
merge(&mut value, layer, "", &mut warnings, false)?;
let tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
validate(&tokens)?;
warnings.extend(authored_warnings(&tokens));
variants[index] = Some(tokens);
}
}
if variants.iter().all(Option::is_none) {
return Err("A theme file must declare a light and/or dark section; partial palettes are allowed.".into());
}
for key in root.as_object().ok_or("Theme file must be a section")?.keys() {
if !matches!(key.as_str(), "name" | "light" | "dark") {
warnings.push(format!("Unknown theme token: {key}"));
}
}
let [light, dark] = variants;
let family = crate::families::Family {
id: id.into(),
name,
light: light.as_ref().map(crate::families::Palette::from),
dark: dark.as_ref().map(crate::families::Palette::from),
};
Ok((family, (light, dark), warnings))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1697,51 +1744,3 @@ mod tests {
assert_eq!(ResolvedTheme::default().transition(&light, 0.), light); assert_eq!(ResolvedTheme::default().transition(&light, 0.), light);
} }
} }
pub fn import_family(
id: &str,
source: &str,
) -> Result<(crate::families::Family, (Option<Tokens>, Option<Tokens>), Vec<String>), String> {
let document = Document::parse(source).map_err(|e| e.to_string())?;
let root = document.get("theme").unwrap_or(document.value());
let name = root.get("name").and_then(Value::as_str).unwrap_or(id).to_owned();
let mut variants = [None, None];
let mut warnings = vec![];
for (index, (key, appearance)) in [("light", Appearance::Light), ("dark", Appearance::Dark)]
.into_iter()
.enumerate()
{
if let Some(layer) = root.get(key) {
let base = preset(
if appearance == Appearance::Light {
"ferese-blue-light"
} else {
"ferese-blue"
},
appearance,
)?;
let mut value = serde_json::to_value(base).map_err(|e| e.to_string())?;
merge(&mut value, layer, "", &mut warnings, false)?;
let tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
validate(&tokens)?;
warnings.extend(authored_warnings(&tokens));
variants[index] = Some(tokens);
}
}
if variants.iter().all(Option::is_none) {
return Err("A theme file must declare a light and/or dark section; partial palettes are allowed.".into());
}
for key in root.as_object().ok_or("Theme file must be a section")?.keys() {
if !matches!(key.as_str(), "name" | "light" | "dark") {
warnings.push(format!("Unknown theme token: {key}"));
}
}
let [light, dark] = variants;
let family = crate::families::Family {
id: id.into(),
name,
light: light.as_ref().map(crate::families::Palette::from),
dark: dark.as_ref().map(crate::families::Palette::from),
};
Ok((family, (light, dark), warnings))
}
+21 -7
View File
@@ -6,17 +6,29 @@ use ferese_config::families::{Family, Palette};
use ferese_config::theme::Appearance; use ferese_config::theme::Appearance;
use iced_accessibility::{A11yTree, accesskit}; use iced_accessibility::{A11yTree, accesskit};
pub struct TileOptions {
pub variant: Option<Appearance>,
pub active: Option<Appearance>,
pub selected: bool,
pub palette: crate::Palette,
pub font: cosmic::font::Font,
pub id: Id,
}
pub fn tile<M: Clone + 'static>( pub fn tile<M: Clone + 'static>(
family: &Family, family: &Family,
variant: Option<Appearance>, options: TileOptions,
active: Option<Appearance>,
selected: bool,
palette: crate::Palette,
font: cosmic::font::Font,
id: Id,
on_select: M, on_select: M,
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'static, navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'static,
) -> Element<'static, M> { ) -> Element<'static, M> {
let TileOptions {
variant,
active,
selected,
palette,
font,
id,
} = options;
use cosmic::widget::{button, column, row}; use cosmic::widget::{button, column, row};
let mut caption = row([]) let mut caption = row([])
.spacing(6) .spacing(6)
@@ -225,13 +237,15 @@ impl cosmic::iced::advanced::widget::operation::Focusable for Focus {
} }
} }
type Navigation<'a, M> = Box<dyn Fn(&keyboard::key::Named) -> Option<M> + 'a>;
pub struct Radio<'a, M> { pub struct Radio<'a, M> {
content: Element<'a, M>, content: Element<'a, M>,
id: Id, id: Id,
selected: bool, selected: bool,
group: bool, group: bool,
label: String, label: String,
navigate: Option<Box<dyn Fn(&keyboard::key::Named) -> Option<M> + 'a>>, navigate: Option<Navigation<'a, M>>,
} }
pub fn radio<'a, M: Clone + 'a>( pub fn radio<'a, M: Clone + 'a>(
+9 -5
View File
@@ -120,8 +120,10 @@ mod tests {
ferese_config::theme::Appearance::Dark, ferese_config::theme::Appearance::Dark,
] { ] {
for high_contrast in [false, true] { for high_contrast in [false, true] {
let mut resolved = ferese_config::theme::ResolvedTheme::default(); let mut resolved = ferese_config::theme::ResolvedTheme {
resolved.appearance = appearance; appearance,
..Default::default()
};
resolved.accessibility.increase_contrast = high_contrast; resolved.accessibility.increase_contrast = high_contrast;
let palette = Palette::from_resolved(&resolved); let palette = Palette::from_resolved(&resolved);
assert_eq!(palette.high_contrast, high_contrast); assert_eq!(palette.high_contrast, high_contrast);
@@ -133,9 +135,11 @@ mod tests {
#[test] #[test]
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() { fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
for preset in crate::PRESETS { for preset in crate::PRESETS {
let mut resolved = ferese_config::theme::ResolvedTheme::default(); let mut resolved = ferese_config::theme::ResolvedTheme {
resolved.appearance = preset.appearance; appearance: preset.appearance,
resolved.tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap(); tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(),
..Default::default()
};
resolved.tokens.material.style = "translucent".into(); resolved.tokens.material.style = "translucent".into();
resolved.tokens.material.opacity = 0.25; resolved.tokens.material.opacity = 0.25;
resolved.tokens.geometry.shell_radius = 0.; resolved.tokens.geometry.shell_radius = 0.;
+23 -9
View File
@@ -393,12 +393,14 @@ impl Backend {
let sharing = start_streams( let sharing = start_streams(
&app_id, &app_id,
&parent_window, &parent_window,
multiple, StreamOptions {
cursor, multiple,
source_types, cursor,
persist_mode, source_types,
restore, requested_persistence: persist_mode,
bar_controlled, restore,
bar_controlled,
},
); );
let result = tokio::select! { let result = tokio::select! {
_ = cancel.wait() => Err(StartError::Cancelled), _ = cancel.wait() => Err(StartError::Cancelled),
@@ -534,16 +536,28 @@ async fn picker(prompt: &Prompt) -> Result<Child, String> {
Ok(child) Ok(child)
} }
async fn start_streams( struct StreamOptions {
app: &str,
parent: &str,
multiple: bool, multiple: bool,
cursor: bool, cursor: bool,
source_types: u32, source_types: u32,
requested_persistence: u32, requested_persistence: u32,
restore: Option<crate::restore::Restore>, restore: Option<crate::restore::Restore>,
bar_controlled: bool, bar_controlled: bool,
}
async fn start_streams(
app: &str,
parent: &str,
options: StreamOptions,
) -> Result<(Vec<Child>, Vec<(u32, Options)>, Options), StartError> { ) -> Result<(Vec<Child>, Vec<(u32, Options)>, Options), StartError> {
let StreamOptions {
multiple,
cursor,
source_types,
requested_persistence,
restore,
bar_controlled,
} = options;
let mut discovery = tokio::task::spawn_blocking(|| Capture::connect(&AtomicBool::new(false))) let mut discovery = tokio::task::spawn_blocking(|| Capture::connect(&AtomicBool::new(false)))
.await .await
.map_err(|e| e.to_string())??; .map_err(|e| e.to_string())??;
+3 -3
View File
@@ -29,9 +29,9 @@ pub(crate) struct ShortcutField {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
ThemeChanged(ferese_config::theme::Snapshot), ThemeChanged(Box<ferese_config::theme::Snapshot>),
Opened(cosmic::iced::window::Id), Opened(cosmic::iced::window::Id),
Attached(Result<(Option<Arc<crate::parent::Parent>>, Result<ModalMaterial, String>), String>), Attached(crate::parent::Attachment),
Accept, Accept,
Cancel, Cancel,
Shortcut(usize, String), Shortcut(usize, String),
@@ -110,7 +110,7 @@ impl cosmic::Application for Consent {
fn subscription(&self) -> cosmic::iced::Subscription<Message> { fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::Subscription::batch([ cosmic::iced::Subscription::batch([
ferese_theme::service::subscription().map(Message::ThemeChanged), ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
cosmic::iced::event::listen_with(|event, _, id| match event { cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => Some(Message::Opened(id)), cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => Some(Message::Opened(id)),
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
+7 -1
View File
@@ -21,8 +21,10 @@ const PATH: &str = "/org/freedesktop/portal/desktop";
const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024; const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024;
type Reply = (u32, Options); type Reply = (u32, Options);
type RequestMap = HashMap<String, (String, Arc<Cancel>)>;
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct Requests(Arc<Mutex<HashMap<String, (String, Arc<Cancel>)>>>, usize); pub(crate) struct Requests(Arc<Mutex<RequestMap>>, usize);
impl Default for Requests { impl Default for Requests {
fn default() -> Self { fn default() -> Self {
@@ -141,6 +143,10 @@ pub(crate) struct Wallpaper(pub(crate) Requests);
#[zbus::interface(name = "org.freedesktop.impl.portal.Wallpaper")] #[zbus::interface(name = "org.freedesktop.impl.portal.Wallpaper")]
impl Wallpaper { impl Wallpaper {
#[zbus(name = "SetWallpaperURI")] #[zbus(name = "SetWallpaperURI")]
#[allow(
clippy::too_many_arguments,
reason = "Signature is defined by the portal D-Bus interface"
)]
async fn set_wallpaper_uri( async fn set_wallpaper_uri(
&self, &self,
handle: OwnedObjectPath, handle: OwnedObjectPath,
+4
View File
@@ -258,6 +258,10 @@ async fn login_inhibitor(what: &str, app: &str, reason: &str) -> Result<Option<O
#[zbus::interface(name = "org.freedesktop.impl.portal.Inhibit")] #[zbus::interface(name = "org.freedesktop.impl.portal.Inhibit")]
impl Inhibit { impl Inhibit {
#[allow(
clippy::too_many_arguments,
reason = "Signature is defined by the portal D-Bus interface"
)]
async fn inhibit( async fn inhibit(
&self, &self,
handle: OwnedObjectPath, handle: OwnedObjectPath,
+8
View File
@@ -1,3 +1,11 @@
pub(crate) type Attachment = Result<
(
Option<std::sync::Arc<Parent>>,
Result<ferese_theme::material::ModalMaterial, String>,
),
String,
>;
use wayland_client::globals::{GlobalListContents, registry_queue_init}; use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::{wl_registry, wl_surface}; use wayland_client::protocol::{wl_registry, wl_surface};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop}; use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
+4
View File
@@ -24,6 +24,10 @@ impl Usb {
1 1
} }
#[allow(
clippy::too_many_arguments,
reason = "Signature is defined by the portal D-Bus interface"
)]
async fn acquire_devices( async fn acquire_devices(
&self, &self,
handle: OwnedObjectPath, handle: OwnedObjectPath,
+3 -11
View File
@@ -52,17 +52,9 @@ pub(crate) struct Selection {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
ThemeChanged(ferese_config::theme::Snapshot), ThemeChanged(Box<ferese_config::theme::Snapshot>),
WindowOpened(cosmic::iced::window::Id), WindowOpened(cosmic::iced::window::Id),
Attached( Attached(crate::parent::Attachment),
Result<
(
Option<std::sync::Arc<crate::parent::Parent>>,
Result<ferese_theme::material::ModalMaterial, String>,
),
String,
>,
),
Select(usize), Select(usize),
Remember, Remember,
Share, Share,
@@ -161,7 +153,7 @@ impl cosmic::Application for Picker {
fn subscription(&self) -> cosmic::iced::Subscription<Message> { fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::Subscription::batch([ cosmic::iced::Subscription::batch([
ferese_theme::service::subscription().map(Message::ThemeChanged), ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
cosmic::iced::event::listen_with(|event, _, id| match event { cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => { cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
Some(Message::WindowOpened(id)) Some(Message::WindowOpened(id))
+4 -2
View File
@@ -282,8 +282,10 @@ mod tests {
#[test] #[test]
fn reports_ferese_theme_and_reduced_motion() { fn reports_ferese_theme_and_reduced_motion() {
let mut resolved = ResolvedTheme::default(); let mut resolved = ResolvedTheme {
resolved.appearance = ThemeAppearance::Light; appearance: ThemeAppearance::Light,
..Default::default()
};
resolved.tokens.colors.accent = "#ff8000".into(); resolved.tokens.colors.accent = "#ff8000".into();
resolved.reduced_motion = true; resolved.reduced_motion = true;
resolved.accessibility.increase_contrast = true; resolved.accessibility.increase_contrast = true;
+4
View File
@@ -310,6 +310,10 @@ impl GlobalShortcuts {
result result
} }
#[allow(
clippy::too_many_arguments,
reason = "Signature is defined by the portal D-Bus interface"
)]
async fn bind_shortcuts( async fn bind_shortcuts(
&self, &self,
handle: OwnedObjectPath, handle: OwnedObjectPath,
+30 -30
View File
@@ -169,6 +169,35 @@ fn account_picture(user: &str) -> Option<image::Handle> {
}) })
} }
pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result<image::Handle, String> {
use ::image::ImageDecoder;
let mut reader = ::image::ImageReader::open(path)
.map_err(|e| e.to_string())?
.with_guessed_format()
.map_err(|e| e.to_string())?;
let mut limits = ::image::Limits::default();
limits.max_alloc = Some(256 * 1024 * 1024);
reader.limits(limits);
let decoder = reader.into_decoder().map_err(|e| e.to_string())?;
let (width, height) = decoder.dimensions();
if u64::from(width) * u64::from(height) * 4 > 256 * 1024 * 1024 {
return Err("Lock wallpaper exceeds the decode limit".into());
}
let pixels = ::image::DynamicImage::from_decoder(decoder)
.map_err(|e| e.to_string())?
.into_rgba8();
let pixels = if blur > 0. {
::image::imageops::blur(&::image::imageops::thumbnail(&pixels, 640, 640), blur)
} else {
pixels
};
Ok(image::Handle::from_rgba(
pixels.width(),
pixels.height(),
pixels.into_raw(),
))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -215,39 +244,10 @@ mod tests {
appearance.accent, appearance.accent,
ferese_theme::parse_color(&theme.tokens.colors.accent).unwrap() ferese_theme::parse_color(&theme.tokens.colors.accent).unwrap()
); );
assert!(ferese_theme::contrast(appearance.accent.into(), appearance.on_accent.into()) >= 4.5); assert!(ferese_theme::contrast(appearance.accent, appearance.on_accent) >= 4.5);
assert!(!appearance.show_clock && !appearance.show_date); assert!(!appearance.show_clock && !appearance.show_date);
assert_eq!(appearance.clock_format(), "%I:%M %p"); assert_eq!(appearance.clock_format(), "%I:%M %p");
assert_eq!(appearance.dim, 0.7); assert_eq!(appearance.dim, 0.7);
assert_eq!(appearance.theme().cosmic().corner_radii.radius_m, [0.; 4]); assert_eq!(appearance.theme().cosmic().corner_radii.radius_m, [0.; 4]);
} }
} }
pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result<image::Handle, String> {
use ::image::ImageDecoder;
let mut reader = ::image::ImageReader::open(path)
.map_err(|e| e.to_string())?
.with_guessed_format()
.map_err(|e| e.to_string())?;
let mut limits = ::image::Limits::default();
limits.max_alloc = Some(256 * 1024 * 1024);
reader.limits(limits);
let decoder = reader.into_decoder().map_err(|e| e.to_string())?;
let (width, height) = decoder.dimensions();
if u64::from(width) * u64::from(height) * 4 > 256 * 1024 * 1024 {
return Err("Lock wallpaper exceeds the decode limit".into());
}
let pixels = ::image::DynamicImage::from_decoder(decoder)
.map_err(|e| e.to_string())?
.into_rgba8();
let pixels = if blur > 0. {
::image::imageops::blur(&::image::imageops::thumbnail(&pixels, 640, 640), blur)
} else {
pixels
};
Ok(image::Handle::from_rgba(
pixels.width(),
pixels.height(),
pixels.into_raw(),
))
}
+2 -2
View File
@@ -82,7 +82,7 @@ fn main() -> iced::Result {
#[derive(Clone)] #[derive(Clone)]
enum Message { enum Message {
ThemeChanged(ferese_config::theme::Snapshot), ThemeChanged(Box<ferese_config::theme::Snapshot>),
WallpaperLoaded(u64, Result<widget::image::Handle, String>), WallpaperLoaded(u64, Result<widget::image::Handle, String>),
Event(Box<Event>), Event(Box<Event>),
Input(String), Input(String),
@@ -193,7 +193,7 @@ impl cosmic::Application for Locker {
if self.preview { if self.preview {
Subscription::none() Subscription::none()
} else { } else {
ferese_theme::service::subscription().map(Message::ThemeChanged) ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot)))
}, },
event::listen_with(|event, _, _| match event { event::listen_with(|event, _, _| match event {
Event::PlatformSpecific(PlatformSpecific::Wayland( Event::PlatformSpecific(PlatformSpecific::Wayland(
+4 -4
View File
@@ -52,7 +52,7 @@ enum Message {
RefreshDisplays, RefreshDisplays,
DisplaysLoaded(Result<Vec<displays::Display>, String>), DisplaysLoaded(Result<Vec<displays::Display>, String>),
RefreshRate(String, displays::Mode, bool), RefreshRate(String, displays::Mode, bool),
ThemeChanged(ferese_config::theme::Snapshot), ThemeChanged(Box<ferese_config::theme::Snapshot>),
DragWindow, DragWindow,
Page(Page), Page(Page),
Search(String), Search(String),
@@ -207,7 +207,7 @@ impl cosmic::Application for App {
return self.edit_many(displays::edits(&prefix, mode, automatic)); return self.edit_many(displays::edits(&prefix, mode, automatic));
} }
Message::ThemeChanged(snapshot) => { Message::ThemeChanged(snapshot) => {
self.resolved = snapshot; self.resolved = *snapshot;
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family)); self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
return self.update_theme(); return self.update_theme();
} }
@@ -596,8 +596,8 @@ impl cosmic::Application for App {
fn subscription(&self) -> cosmic::iced::Subscription<Message> { fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::Subscription::batch([ cosmic::iced::Subscription::batch([
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes), cosmic::iced::Subscription::run_with(self.path.clone(), |path| watch::changes(path)),
ferese_theme::service::subscription().map(Message::ThemeChanged), ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
if self.page == Page::Displays { if self.page == Page::Displays {
cosmic::iced::time::every(std::time::Duration::from_secs(2)).map(|_| Message::RefreshDisplays) cosmic::iced::time::every(std::time::Duration::from_secs(2)).map(|_| Message::RefreshDisplays)
} else { } else {
+8 -6
View File
@@ -412,12 +412,14 @@ impl App {
format!("{prefix}.scale"), format!("{prefix}.scale"),
"Scale", "Scale",
"Logical size of text and controls.", "Logical size of text and controls.",
1., schema::RangeSpec {
0.75, default: 1.,
3., min: 0.75,
0.25, max: 3.,
"×", step: 0.25,
false, suffix: "×",
integer: false,
},
))); )));
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
} }
+347 -252
View File
@@ -148,17 +148,30 @@ fn toggle(path: &str, label: &str, description: &str, default: bool) -> Field {
Field::new(path, label, description, Kind::Toggle(default)) Field::new(path, label, description, Kind::Toggle(default))
} }
#[derive(Clone, Copy, Debug)]
pub struct RangeSpec {
pub default: f64,
pub min: f64,
pub max: f64,
pub step: f64,
pub suffix: &'static str,
pub integer: bool,
}
pub fn range( pub fn range(
path: impl Into<String>, path: impl Into<String>,
label: impl Into<String>, label: impl Into<String>,
description: impl Into<String>, description: impl Into<String>,
default: f64, spec: RangeSpec,
min: f64,
max: f64,
step: f64,
suffix: &'static str,
integer: bool,
) -> Field { ) -> Field {
let RangeSpec {
default,
min,
max,
step,
suffix,
integer,
} = spec;
Field::new( Field::new(
path, path,
label, label,
@@ -278,100 +291,118 @@ fn clock_fields() -> Vec<Field> {
"desktop_widgets.clock.margin_x", "desktop_widgets.clock.margin_x",
"Horizontal margin", "Horizontal margin",
"Distance from left/right edge; ignored when horizontally centered.", "Distance from left/right edge; ignored when horizontally centered.",
64.0, RangeSpec {
0.0, default: 64.0,
1000.0, min: 0.0,
1.0, max: 1000.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
range( range(
"desktop_widgets.clock.margin_y", "desktop_widgets.clock.margin_y",
"Vertical margin", "Vertical margin",
"Distance from top/bottom edge; ignored when vertically centered.", "Distance from top/bottom edge; ignored when vertically centered.",
80.0, RangeSpec {
0.0, default: 80.0,
1000.0, min: 0.0,
1.0, max: 1000.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
range( range(
"desktop_widgets.clock.width", "desktop_widgets.clock.width",
"Widget width", "Widget width",
"Logical pixels; increase for longer formats.", "Logical pixels; increase for longer formats.",
440.0, RangeSpec {
64.0, default: 440.0,
1600.0, min: 64.0,
1.0, max: 1600.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
range( range(
"desktop_widgets.clock.height", "desktop_widgets.clock.height",
"Widget height", "Widget height",
"Leave room for both text lines.", "Leave room for both text lines.",
320.0, RangeSpec {
32.0, default: 320.0,
800.0, min: 32.0,
1.0, max: 800.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
range( range(
"desktop_widgets.clock.time_size", "desktop_widgets.clock.time_size",
"Time size", "Time size",
"Logical pixels; scales with your output.", "Logical pixels; scales with your output.",
128.0, RangeSpec {
8.0, default: 128.0,
240.0, min: 8.0,
1.0, max: 240.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
range( range(
"desktop_widgets.clock.date_size", "desktop_widgets.clock.date_size",
"Date size", "Date size",
"Separate size for the date line.", "Separate size for the date line.",
18.0, RangeSpec {
8.0, default: 18.0,
96.0, min: 8.0,
1.0, max: 96.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
range( range(
"desktop_widgets.clock.opacity", "desktop_widgets.clock.opacity",
"Opacity", "Opacity",
"Applies to text and the optional background.", "Applies to text and the optional background.",
0.9, RangeSpec {
0.0, default: 0.9,
1.0, min: 0.0,
0.05, max: 1.0,
"", step: 0.05,
false, suffix: "",
integer: false,
},
), ),
range( range(
"desktop_widgets.clock.gap", "desktop_widgets.clock.gap",
"Line spacing", "Line spacing",
"Space between time and date.", "Space between time and date.",
4.0, RangeSpec {
0.0, default: 4.0,
64.0, min: 0.0,
1.0, max: 64.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
range( range(
"desktop_widgets.clock.padding", "desktop_widgets.clock.padding",
"Inner padding", "Inner padding",
"Space around the labels.", "Space around the labels.",
12.0, RangeSpec {
0.0, default: 12.0,
64.0, min: 0.0,
1.0, max: 64.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
choice( choice(
"desktop_widgets.clock.alignment", "desktop_widgets.clock.alignment",
@@ -422,100 +453,118 @@ pub fn note_fields(index: usize) -> Vec<Field> {
format!("{prefix}.width"), format!("{prefix}.width"),
"Width", "Width",
"Logical pixels; centered axes ignore margins.", "Logical pixels; centered axes ignore margins.",
320.0, RangeSpec {
120.0, default: 320.0,
1200.0, min: 120.0,
1.0, max: 1200.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
range( range(
format!("{prefix}.height"), format!("{prefix}.height"),
"Height", "Height",
"Logical pixels; centered axes ignore margins.", "Logical pixels; centered axes ignore margins.",
240.0, RangeSpec {
80.0, default: 240.0,
1200.0, min: 80.0,
1.0, max: 1200.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
range( range(
format!("{prefix}.margin_x"), format!("{prefix}.margin_x"),
"Horizontal margin", "Horizontal margin",
"Logical pixels; centered axes ignore margins.", "Logical pixels; centered axes ignore margins.",
48.0, RangeSpec {
0.0, default: 48.0,
1000.0, min: 0.0,
1.0, max: 1000.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
range( range(
format!("{prefix}.margin_y"), format!("{prefix}.margin_y"),
"Vertical margin", "Vertical margin",
"Logical pixels; centered axes ignore margins.", "Logical pixels; centered axes ignore margins.",
80.0, RangeSpec {
0.0, default: 80.0,
1000.0, min: 0.0,
1.0, max: 1000.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
range( range(
format!("{prefix}.text_size"), format!("{prefix}.text_size"),
"Text size", "Text size",
"Logical pixels; centered axes ignore margins.", "Logical pixels; centered axes ignore margins.",
16.0, RangeSpec {
8.0, default: 16.0,
96.0, min: 8.0,
1.0, max: 96.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
range( range(
format!("{prefix}.title_size"), format!("{prefix}.title_size"),
"Title size", "Title size",
"Logical pixels; centered axes ignore margins.", "Logical pixels; centered axes ignore margins.",
18.0, RangeSpec {
8.0, default: 18.0,
96.0, min: 8.0,
1.0, max: 96.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
range( range(
format!("{prefix}.opacity"), format!("{prefix}.opacity"),
"Opacity", "Opacity",
"Logical pixels; centered axes ignore margins.", "Logical pixels; centered axes ignore margins.",
0.9, RangeSpec {
0.0, default: 0.9,
1.0, min: 0.0,
0.05, max: 1.0,
"", step: 0.05,
false, suffix: "",
integer: false,
},
), ),
range( range(
format!("{prefix}.padding"), format!("{prefix}.padding"),
"Padding", "Padding",
"Logical pixels; centered axes ignore margins.", "Logical pixels; centered axes ignore margins.",
20.0, RangeSpec {
0.0, default: 20.0,
64.0, min: 0.0,
1.0, max: 64.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
range( range(
format!("{prefix}.gap"), format!("{prefix}.gap"),
"Title spacing", "Title spacing",
"Logical pixels; centered axes ignore margins.", "Logical pixels; centered axes ignore margins.",
8.0, RangeSpec {
0.0, default: 8.0,
64.0, min: 0.0,
1.0, max: 64.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
Field::new( Field::new(
format!("{prefix}.alignment"), format!("{prefix}.alignment"),
@@ -536,23 +585,27 @@ pub fn fields(page: Page) -> Vec<Field> {
"lock_screen.dim_after_seconds", "lock_screen.dim_after_seconds",
"Dim after", "Dim after",
"Dim the locked display after inactivity. Zero disables dimming.", "Dim the locked display after inactivity. Zero disables dimming.",
30.0, RangeSpec {
0.0, default: 30.0,
600.0, min: 0.0,
5.0, max: 600.0,
"s", step: 5.0,
true, suffix: "s",
integer: true,
},
), ),
range( range(
"lock_screen.sleep_after_seconds", "lock_screen.sleep_after_seconds",
"Display sleep after", "Display sleep after",
"Turn off locked displays after inactivity. Input wakes them; zero disables display sleep.", "Turn off locked displays after inactivity. Input wakes them; zero disables display sleep.",
120.0, RangeSpec {
0.0, default: 120.0,
1800.0, min: 0.0,
15.0, max: 1800.0,
"s", step: 15.0,
true, suffix: "s",
integer: true,
},
), ),
toggle( toggle(
"lock_screen.show_clock", "lock_screen.show_clock",
@@ -577,23 +630,27 @@ pub fn fields(page: Page) -> Vec<Field> {
"lock_screen.background_blur", "lock_screen.background_blur",
"Wallpaper softness", "Wallpaper softness",
"Blur the lock screen wallpaper. Zero keeps the original image sharp.", "Blur the lock screen wallpaper. Zero keeps the original image sharp.",
18.0, RangeSpec {
0.0, default: 18.0,
40.0, min: 0.0,
1.0, max: 40.0,
"", step: 1.0,
false, suffix: "",
integer: false,
},
), ),
range( range(
"lock_screen.background_dim", "lock_screen.background_dim",
"Wallpaper dimming", "Wallpaper dimming",
"Darken the wallpaper behind the clock and sign-in card.", "Darken the wallpaper behind the clock and sign-in card.",
0.48, RangeSpec {
0.0, default: 0.48,
0.9, min: 0.0,
0.02, max: 0.9,
"", step: 0.02,
false, suffix: "",
integer: false,
},
), ),
], ],
Page::Accessibility => vec![ Page::Accessibility => vec![
@@ -664,12 +721,14 @@ pub fn fields(page: Page) -> Vec<Field> {
"theme.geometry.shell_radius", "theme.geometry.shell_radius",
"Shell corner radius", "Shell corner radius",
"Rounds the bar, menus, notifications, desktop widgets and hover backgrounds. Window corners are separate.", "Rounds the bar, menus, notifications, desktop widgets and hover backgrounds. Window corners are separate.",
14.0, RangeSpec {
0.0, default: 14.0,
32.0, min: 0.0,
1.0, max: 32.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
text( text(
"theme.accent", "theme.accent",
@@ -688,23 +747,27 @@ pub fn fields(page: Page) -> Vec<Field> {
"theme.material.opacity", "theme.material.opacity",
"Shell opacity", "Shell opacity",
"Background opacity for the bar, popovers, menus, notifications and sharing dialog. Text and icons stay opaque.", "Background opacity for the bar, popovers, menus, notifications and sharing dialog. Text and icons stay opaque.",
ferese_config::DEFAULT_MATERIAL_OPACITY, RangeSpec {
0.0, default: ferese_config::DEFAULT_MATERIAL_OPACITY,
1.0, min: 0.0,
0.01, max: 1.0,
"", step: 0.01,
false, suffix: "",
integer: false,
},
), ),
range( range(
"theme.material.blur_radius", "theme.material.blur_radius",
"Background blur", "Background blur",
"Softness behind translucent surfaces.", "Softness behind translucent surfaces.",
12.0, RangeSpec {
0.0, default: 12.0,
32.0, min: 0.0,
1.0, max: 32.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
text( text(
"theme.typography.font_family", "theme.typography.font_family",
@@ -716,12 +779,14 @@ pub fn fields(page: Page) -> Vec<Field> {
"theme.geometry.focus_ring_width", "theme.geometry.focus_ring_width",
"Focus border", "Focus border",
"A subtle outline around the active window.", "A subtle outline around the active window.",
2.0, RangeSpec {
0.0, default: 2.0,
4.0, min: 0.0,
0.5, max: 4.0,
" px", step: 0.5,
false, suffix: " px",
integer: false,
},
), ),
], ],
Page::Wallpaper => vec![ Page::Wallpaper => vec![
@@ -757,12 +822,14 @@ pub fn fields(page: Page) -> Vec<Field> {
"notifications.timeout_ms", "notifications.timeout_ms",
"Popup timeout", "Popup timeout",
"Default duration; apps can request another timeout. Pauses while hovered.", "Default duration; apps can request another timeout. Pauses while hovered.",
6000.0, RangeSpec {
1000.0, default: 6000.0,
30000.0, min: 1000.0,
1000.0, max: 30000.0,
" ms", step: 1000.0,
true, suffix: " ms",
integer: true,
},
), ),
], ],
Page::Bar => vec![ Page::Bar => vec![
@@ -776,45 +843,53 @@ pub fn fields(page: Page) -> Vec<Field> {
"theme.geometry.top_bar_height", "theme.geometry.top_bar_height",
"Height", "Height",
"Keeps icon and text sizes unchanged.", "Keeps icon and text sizes unchanged.",
28.0, RangeSpec {
24.0, default: 28.0,
48.0, min: 24.0,
1.0, max: 48.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
range( range(
"theme.geometry.top_bar_margin_top", "theme.geometry.top_bar_margin_top",
"Top margin", "Top margin",
"Set to zero for an edge-to-edge bar.", "Set to zero for an edge-to-edge bar.",
0.0, RangeSpec {
0.0, default: 0.0,
24.0, min: 0.0,
1.0, max: 24.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
range( range(
"theme.geometry.top_bar_margin_horizontal", "theme.geometry.top_bar_margin_horizontal",
"Side margins", "Side margins",
"Space between the bar and the display edges.", "Space between the bar and the display edges.",
0.0, RangeSpec {
0.0, default: 0.0,
32.0, min: 0.0,
1.0, max: 32.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
range( range(
"theme.geometry.top_bar_window_gap", "theme.geometry.top_bar_window_gap",
"Window clearance", "Window clearance",
"Space below the menu bar.", "Space below the menu bar.",
0.0, RangeSpec {
0.0, default: 0.0,
24.0, min: 0.0,
1.0, max: 24.0,
" px", step: 1.0,
true, suffix: " px",
integer: true,
},
), ),
toggle( toggle(
"status.battery_percentage", "status.battery_percentage",
@@ -852,45 +927,53 @@ pub fn fields(page: Page) -> Vec<Field> {
"scrolling.default_column_width", "scrolling.default_column_width",
"New window width", "New window width",
"Fraction of the available workspace.", "Fraction of the available workspace.",
0.5, RangeSpec {
0.25, default: 0.5,
1.0, min: 0.25,
0.05, max: 1.0,
"", step: 0.05,
false, suffix: "",
integer: false,
},
), ),
range( range(
"layout.inner_gap", "layout.inner_gap",
"Between windows", "Between windows",
"Space between neighboring windows.", "Space between neighboring windows.",
10.0, RangeSpec {
0.0, default: 10.0,
32.0, min: 0.0,
1.0, max: 32.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
range( range(
"layout.outer_gap", "layout.outer_gap",
"Workspace edges", "Workspace edges",
"Space around the outside of the layout.", "Space around the outside of the layout.",
4.0, RangeSpec {
0.0, default: 4.0,
32.0, min: 0.0,
1.0, max: 32.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
range( range(
"theme.geometry.window_radius", "theme.geometry.window_radius",
"Corner radius", "Corner radius",
"Rounded corners for application windows.", "Rounded corners for application windows.",
14.0, RangeSpec {
0.0, default: 14.0,
28.0, min: 0.0,
1.0, max: 28.0,
" px", step: 1.0,
false, suffix: " px",
integer: false,
},
), ),
toggle( toggle(
"appearance.inactive_dim.enabled", "appearance.inactive_dim.enabled",
@@ -902,12 +985,14 @@ pub fn fields(page: Page) -> Vec<Field> {
"appearance.inactive_dim.amount", "appearance.inactive_dim.amount",
"Dimming amount", "Dimming amount",
"How much inactive windows recede.", "How much inactive windows recede.",
0.15, RangeSpec {
0.0, default: 0.15,
0.5, min: 0.0,
0.01, max: 0.5,
"", step: 0.01,
false, suffix: "",
integer: false,
},
), ),
], ],
Page::Motion => vec![ Page::Motion => vec![
@@ -921,23 +1006,27 @@ pub fn fields(page: Page) -> Vec<Field> {
"animations.speed", "animations.speed",
"Animation speed", "Animation speed",
"Lower values give transitions more time.", "Lower values give transitions more time.",
1.0, RangeSpec {
0.25, default: 1.0,
2.0, min: 0.25,
0.05, max: 2.0,
"×", step: 0.05,
false, suffix: "×",
integer: false,
},
), ),
range( range(
"appearance.inactive_dim.duration_ms", "appearance.inactive_dim.duration_ms",
"Focus transition", "Focus transition",
"Time taken to dim an inactive window.", "Time taken to dim an inactive window.",
150.0, RangeSpec {
0.0, default: 150.0,
600.0, min: 0.0,
10.0, max: 600.0,
" ms", step: 10.0,
false, suffix: " ms",
integer: false,
},
), ),
], ],
Page::Keyboard => vec![ Page::Keyboard => vec![
@@ -957,23 +1046,27 @@ pub fn fields(page: Page) -> Vec<Field> {
"input.repeat_rate", "input.repeat_rate",
"Key repeat", "Key repeat",
"Characters repeated each second while a key is held.", "Characters repeated each second while a key is held.",
25.0, RangeSpec {
1.0, default: 25.0,
70.0, min: 1.0,
1.0, max: 70.0,
" /s", step: 1.0,
true, suffix: " /s",
integer: true,
},
), ),
range( range(
"input.repeat_delay_ms", "input.repeat_delay_ms",
"Delay before repeat", "Delay before repeat",
"How long to hold a key before it starts repeating.", "How long to hold a key before it starts repeating.",
600.0, RangeSpec {
150.0, default: 600.0,
1000.0, min: 150.0,
25.0, max: 1000.0,
" ms", step: 25.0,
true, suffix: " ms",
integer: true,
},
), ),
toggle( toggle(
"input.focus_follows_mouse", "input.focus_follows_mouse",
@@ -1003,12 +1096,14 @@ pub fn fields(page: Page) -> Vec<Field> {
"input.touchpad.swipe_threshold", "input.touchpad.swipe_threshold",
"Swipe distance", "Swipe distance",
"Distance needed to navigate. Larger values reduce accidental swipes.", "Distance needed to navigate. Larger values reduce accidental swipes.",
80.0, RangeSpec {
16.0, default: 80.0,
1000.0, min: 16.0,
8.0, max: 1000.0,
" px", step: 8.0,
true, suffix: " px",
integer: true,
},
), ),
], ],
Page::Shortcuts => vec![toggle( Page::Shortcuts => vec![toggle(
+8 -6
View File
@@ -199,12 +199,14 @@ impl App {
let tile_id = gallery_id(&id, appearance); let tile_id = gallery_id(&id, appearance);
tiles = tiles.push(gallery::tile( tiles = tiles.push(gallery::tile(
family, family,
appearance, gallery::TileOptions {
active, variant: appearance,
selected, active,
palette, selected,
self.font, palette,
tile_id, font: self.font,
id: tile_id,
},
Message::Family(id, appearance), Message::Family(id, appearance),
move |key| { move |key| {
gallery::neighbor(index, ids.len(), key) gallery::neighbor(index, ids.len(), key)
+12 -8
View File
@@ -294,9 +294,11 @@ mod tests {
fn presets_are_distinct_and_readable_on_settings_surfaces() { fn presets_are_distinct_and_readable_on_settings_surfaces() {
for (index, preset) in PRESETS.iter().enumerate() { for (index, preset) in PRESETS.iter().enumerate() {
let tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap(); let tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap();
let mut theme = ferese_config::theme::ResolvedTheme::default(); let theme = ferese_config::theme::ResolvedTheme {
theme.appearance = preset.appearance; appearance: preset.appearance,
theme.tokens = tokens; tokens,
..Default::default()
};
let palette = Palette::from_resolved(&theme); let palette = Palette::from_resolved(&theme);
for background in [palette.background, palette.sidebar, palette.card] { for background in [palette.background, palette.sidebar, palette.card] {
assert!(contrast(palette.text, background) >= 4.5, "{} text", preset.name); assert!(contrast(palette.text, background) >= 4.5, "{} text", preset.name);
@@ -313,10 +315,10 @@ mod tests {
#[test] #[test]
fn selection_tracks_all_preset_colors_and_gradient_changes() { fn selection_tracks_all_preset_colors_and_gradient_changes() {
for index in 0..PRESETS.len() { for (index, preset) in PRESETS.iter().enumerate() {
let mut snapshot = configured_preset(index); let mut snapshot = configured_preset(index);
assert!(preset_selected(&snapshot, index)); assert!(preset_selected(&snapshot, index));
let kind = if PRESETS[index].appearance == ferese_config::theme::Appearance::Light { let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
"light" "light"
} else { } else {
"dark" "dark"
@@ -343,9 +345,11 @@ mod tests {
for (index, item) in PRESETS.iter().enumerate() { for (index, item) in PRESETS.iter().enumerate() {
let _ = index; let _ = index;
let mut resolved = ferese_config::theme::ResolvedTheme::default(); let resolved = ferese_config::theme::ResolvedTheme {
resolved.appearance = item.appearance; appearance: item.appearance,
resolved.tokens = ferese_config::theme::preset(item.id, item.appearance).unwrap(); tokens: ferese_config::theme::preset(item.id, item.appearance).unwrap(),
..Default::default()
};
assert_eq!( assert_eq!(
Palette::from_resolved(&resolved).native_theme().cosmic().is_dark, Palette::from_resolved(&resolved).native_theme().cosmic().is_dark,
item.appearance == ferese_config::theme::Appearance::Dark item.appearance == ferese_config::theme::Appearance::Dark
+3 -3
View File
@@ -1,5 +1,5 @@
//! Watch the parent directory so atomic editor saves keep working. //! Watch the parent directory so atomic editor saves keep working.
use std::path::PathBuf; use std::path::Path;
use std::time::Duration; use std::time::Duration;
use cosmic::iced::futures::{SinkExt, Stream}; use cosmic::iced::futures::{SinkExt, Stream};
@@ -8,8 +8,8 @@ use notify::{RecursiveMode, Watcher};
use crate::Message; use crate::Message;
use crate::store::Snapshot; use crate::store::Snapshot;
pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> { pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
let path = path.clone(); let path = path.to_path_buf();
cosmic::iced::stream::channel(1, async move |mut output| { cosmic::iced::stream::channel(1, async move |mut output| {
let (send, mut events) = tokio::sync::mpsc::channel(1); let (send, mut events) = tokio::sync::mpsc::channel(1);
+2 -10
View File
@@ -37,7 +37,6 @@ pub(crate) struct WorkspaceSnapshot {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct WindowSnapshot { pub(crate) struct WindowSnapshot {
pub(crate) id: u64,
pub(crate) workspace: u64, pub(crate) workspace: u64,
pub(crate) app_id: String, pub(crate) app_id: String,
pub(crate) title: String, pub(crate) title: String,
@@ -138,12 +137,6 @@ impl ShellControl {
let _ = self.connection.flush(); let _ = self.connection.flush();
} }
pub(crate) fn activate_window(&self, id: u64) {
let (hi, lo) = split_id(id);
self.shell.activate_window(hi, lo);
let _ = self.connection.flush();
}
pub(crate) fn confirm_logout(&self, serial: u32, revision: u32, token: u32, force: bool) { pub(crate) fn confirm_logout(&self, serial: u32, revision: u32, token: u32, force: bool) {
self.shell self.shell
.confirm_logout_with_inhibitors(serial, revision, token, u32::from(force)); .confirm_logout_with_inhibitors(serial, revision, token, u32::from(force));
@@ -268,8 +261,8 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
}); });
} }
ferese_shell_v1::Event::Window { ferese_shell_v1::Event::Window {
window_hi, window_hi: _,
window_lo, window_lo: _,
workspace_hi, workspace_hi,
workspace_lo, workspace_lo,
app_id, app_id,
@@ -285,7 +278,6 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
}; };
state.pending.windows.push(WindowSnapshot { state.pending.windows.push(WindowSnapshot {
id: join_id(window_hi, window_lo),
workspace: join_id(workspace_hi, workspace_lo), workspace: join_id(workspace_hi, workspace_lo),
app_id, app_id,
title, title,
+2 -3
View File
@@ -221,7 +221,7 @@ struct OutputSurfaces {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
ThemeChanged(ferese_config::theme::Snapshot), ThemeChanged(Box<ferese_config::theme::Snapshot>),
ThemeMode(ferese_config::theme::Mode), ThemeMode(ferese_config::theme::Mode),
ThemeModeSet(Result<(), String>), ThemeModeSet(Result<(), String>),
BeginNoteEdit(String), BeginNoteEdit(String),
@@ -340,7 +340,7 @@ impl cosmic::Application for FereseShell {
fn subscription(&self) -> Subscription<Self::Message> { fn subscription(&self) -> Subscription<Self::Message> {
Subscription::batch([ Subscription::batch([
ferese_theme::service::subscription().map(Message::ThemeChanged), ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))),
if self if self
.system_modal .system_modal
.as_ref() .as_ref()
@@ -1208,7 +1208,6 @@ mod tests {
}], }],
workspaces: Vec::new(), workspaces: Vec::new(),
windows: vec![WindowSnapshot { windows: vec![WindowSnapshot {
id: 1,
workspace, workspace,
app_id: "dev.ferese.Test".to_owned(), app_id: "dev.ferese.Test".to_owned(),
title: "Test".to_owned(), title: "Test".to_owned(),
+3 -11
View File
@@ -6,7 +6,7 @@ use super::bar::status_icon;
use super::controls::status_summary; use super::controls::status_summary;
use super::{Menu, MenuRows, MenuStyle}; use super::{Menu, MenuRows, MenuStyle};
use crate::status::Action; use crate::status::Action;
use crate::{FereseShell, Message, color_with_opacity, text}; use crate::{FereseShell, Message, text};
const DEVICE_LIST_HEIGHT: f32 = 180.0; const DEVICE_LIST_HEIGHT: f32 = 180.0;
@@ -15,12 +15,7 @@ fn device_list_height(count: usize) -> Option<f32> {
} }
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
let MenuStyle { let MenuStyle { muted, .. } = style;
theme,
primary,
muted,
opacity: p,
} = style;
if let Some(b) = &shell.status.bluetooth { if let Some(b) = &shell.status.bluetooth {
rows = rows.push(status_summary( rows = rows.push(status_summary(
status_icon(Menu::Bluetooth, &shell.status).0, status_icon(Menu::Bluetooth, &shell.status).0,
@@ -36,10 +31,7 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
} else { } else {
"Your connected devices" "Your connected devices"
}, },
primary, style,
muted,
color_with_opacity(theme.accent, p),
p,
b.enabled, b.enabled,
Some((b.enabled, Action::Bluetooth(!b.enabled))), Some((b.enabled, Action::Bluetooth(!b.enabled))),
)); ));
+9 -5
View File
@@ -3,19 +3,23 @@ use cosmic::widget::{button, column, container, row, slider};
use cosmic::{Element, theme}; use cosmic::{Element, theme};
use crate::status::Action; use crate::status::Action;
use crate::{Message, ShellTheme, accented_icon, color, motion, shell_font, text}; use crate::{Message, ShellTheme, accented_icon, color, color_with_opacity, motion, shell_font, text};
pub(super) fn status_summary<'a>( pub(super) fn status_summary<'a>(
source: &'static [u8], source: &'static [u8],
title: &'a str, title: &'a str,
subtitle: &'a str, subtitle: &'a str,
primary: Color, style: super::MenuStyle,
muted: Color,
accent: Color,
opacity: f32,
enabled: bool, enabled: bool,
toggle: Option<(bool, Action)>, toggle: Option<(bool, Action)>,
) -> Element<'a, cosmic::Action<Message>> { ) -> Element<'a, cosmic::Action<Message>> {
let super::MenuStyle {
theme,
primary,
muted,
opacity,
} = style;
let accent = color_with_opacity(theme.accent, opacity);
let badge_color = if enabled { accent } else { primary }; let badge_color = if enabled { accent } else { primary };
let badge = container(accented_icon(source, 22, primary, accent)) let badge = container(accented_icon(source, 22, primary, accent))
.width(32) .width(32)
+3 -9
View File
@@ -5,14 +5,11 @@ use super::bar::status_icon;
use super::controls::{level_meter, shell_switch, status_summary}; use super::controls::{level_meter, shell_switch, status_summary};
use super::{Menu, MenuRows, MenuStyle}; use super::{Menu, MenuRows, MenuStyle};
use crate::status::Action; use crate::status::Action;
use crate::{FereseShell, color_with_opacity, text}; use crate::{FereseShell, text};
pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> {
let MenuStyle { let MenuStyle {
theme, primary, opacity: p, ..
primary,
muted,
opacity: p,
} = style; } = style;
if let Some(n) = &shell.status.network { if let Some(n) = &shell.status.network {
let connected = n.enabled && n.connection.is_some(); let connected = n.enabled && n.connection.is_some();
@@ -30,10 +27,7 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
} else { } else {
"Wireless connections are paused" "Wireless connections are paused"
}, },
primary, style,
muted,
color_with_opacity(theme.accent, p),
p,
n.enabled, n.enabled,
None, None,
)); ));
+15 -15
View File
@@ -178,21 +178,6 @@ fn connection_control<'a>(
container(tile).width(Length::FillPortion(1)).into() container(tile).width(Length::FillPortion(1)).into()
} }
#[cfg(test)]
mod tests {
#[test]
fn connection_captions_fit_one_short_line() {
assert_eq!(super::connection_caption("88%"), "88%");
assert_eq!(super::connection_caption("Flow84@Lofree"), "Flow84@Lofr…");
assert_eq!(super::connection_caption("123456789012"), "123456789012");
assert_eq!(super::connection_caption("My very long headphones"), "My very lon…");
assert_eq!(super::connection_caption(" Device\n name "), "Device name");
let caption = super::connection_caption("耳機耳機耳機耳機耳機耳機耳機");
assert_eq!(caption.chars().count(), 12);
assert!(caption.ends_with('…'));
}
}
pub(super) fn heading<'a>( pub(super) fn heading<'a>(
shell: &'a FereseShell, shell: &'a FereseShell,
mut heading: super::MenuHeading<'a>, mut heading: super::MenuHeading<'a>,
@@ -229,3 +214,18 @@ pub(super) fn heading<'a>(
} }
heading heading
} }
#[cfg(test)]
mod tests {
#[test]
fn connection_captions_fit_one_short_line() {
assert_eq!(super::connection_caption("88%"), "88%");
assert_eq!(super::connection_caption("Flow84@Lofree"), "Flow84@Lofr…");
assert_eq!(super::connection_caption("123456789012"), "123456789012");
assert_eq!(super::connection_caption("My very long headphones"), "My very lon…");
assert_eq!(super::connection_caption(" Device\n name "), "Device name");
let caption = super::connection_caption("耳機耳機耳機耳機耳機耳機耳機");
assert_eq!(caption.chars().count(), 12);
assert!(caption.ends_with('…'));
}
}