diff --git a/Cargo.toml b/Cargo.toml index 22c255e..6162875 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ resolver = "3" version = "0.1.0" edition = "2024" license = "MIT" -rust-version = "1.87" +rust-version = "1.98" [workspace.dependencies] bitflags = "2.9.4" diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 2d5ef1b..67af6ae 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -1101,7 +1101,7 @@ fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile]) -> io::Result Option> { } fn decode_regions(bytes: &[u8]) -> Option> { - if bytes.len() % 20 != 0 || bytes.len() > 32 * 20 { + if !bytes.len().is_multiple_of(20) || bytes.len() > 32 * 20 { return None; } bytes - .chunks_exact(20) + .as_chunks::<20>() + .0 + .iter() .map(|tuple| { - let values: Vec<_> = tuple - .chunks_exact(4) - .map(|v| i32::from_ne_bytes(v.try_into().unwrap())) - .collect(); + let values: [i32; 5] = + std::array::from_fn(|index| i32::from_ne_bytes(tuple.as_chunks::<4>().0[index])); (values[2] > 0 && values[3] > 0 && values[4] >= 0 && values.iter().all(|v| v.abs_diff(0) <= 32768)) - .then(|| [values[0], values[1], values[2], values[3], values[4]]) + .then_some(values) }) .collect() } diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index c791fd0..553fca8 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -110,19 +110,6 @@ impl CompositorHandler for Ferese { } } -#[cfg(test)] -mod tests { - use super::*; - use smithay::wayland::shell::wlr_layer::Layer; - #[test] - fn panel_commits_do_not_invalidate_their_own_backdrop() { - assert!(!layer_affects_backdrop(Layer::Top)); - assert!(!layer_affects_backdrop(Layer::Overlay)); - assert!(layer_affects_backdrop(Layer::Bottom)); - assert!(layer_affects_backdrop(Layer::Background)); - } -} - impl BufferHandler for Ferese { fn buffer_destroyed(&mut self, _buffer: &wl_buffer::WlBuffer) {} } @@ -148,3 +135,16 @@ impl ShmHandler for Ferese { &self.shm_state } } + +#[cfg(test)] +mod tests { + use super::*; + use smithay::wayland::shell::wlr_layer::Layer; + #[test] + fn panel_commits_do_not_invalidate_their_own_backdrop() { + assert!(!layer_affects_backdrop(Layer::Top)); + assert!(!layer_affects_backdrop(Layer::Overlay)); + assert!(layer_affects_backdrop(Layer::Bottom)); + assert!(layer_affects_backdrop(Layer::Background)); + } +} diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 94b4fce..3ab7bc3 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -107,10 +107,10 @@ fn main() -> Result<(), Box> { let reload_handle = event_loop.handle(); let result = event_loop.run(None, &mut state, |state| { if let Some(monitor) = &mut monitor { - if let Some(result) = monitor.poll(std::time::Instant::now()) { - if let Err(error) = result.and_then(|source| state.reload_config_source(source)) { - warn!(%error, "config reload rejected; retaining last working config"); - } + if let Some(result) = monitor.poll(std::time::Instant::now()) + && let Err(error) = result.and_then(|source| state.reload_config_source(source)) + { + warn!(%error, "config reload rejected; retaining last working config"); } // At most one timer exists. Edits arriving during debounce move the // deadline; the old timer wakes once and is replaced if necessary. diff --git a/compositor/src/metrics.rs b/compositor/src/metrics.rs index 14a2cf0..14def73 100644 --- a/compositor/src/metrics.rs +++ b/compositor/src/metrics.rs @@ -148,7 +148,7 @@ mod tests { Duration::from_millis(2), &[Rectangle::new((0, 0).into(), (100, 50).into())], 0, - FrameEffectMetrics::default(), + FrameEffectMetrics, ); assert_eq!(metrics.rendered_frames, 0); diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 32bdcb3..9b1fc16 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -92,6 +92,14 @@ pub(crate) fn init_font_loader( Ok(()) } +type OverviewStateLabel = HashMap< + (String, u64, [u32; 4]), + ( + MemoryRenderBuffer, + smithay::utils::Size, + ), +>; + #[derive(Debug)] pub(crate) struct OverviewState { active: bool, @@ -103,13 +111,7 @@ pub(crate) struct OverviewState { font_family: String, font_requests: Option, font: Option, - labels: HashMap< - (String, u64, [u32; 4]), - ( - MemoryRenderBuffer, - smithay::utils::Size, - ), - >, + labels: OverviewStateLabel, } impl Default for OverviewState { @@ -480,6 +482,7 @@ impl Ferese { let selected = self .window_under_visual(point) .and_then(|w| self.window_ids.get(&w).copied()); + if selected.is_some() && self.overview.selected != selected { self.overview.selected = selected; crate::backends::direct::render_all(self); @@ -655,8 +658,7 @@ impl Ferese { let card_width = ((strip.width - 24.0 - WORKSPACE_CARD_GAP * capacity.saturating_sub(1) as f64) / capacity as f64) - .min(160.0) - .max(1.0); + .clamp(1.0, 160.0); let visible_count = workspaces.len().saturating_sub(offset).min(capacity); let start_x = centered_row_start(strip, visible_count, card_width); workspaces diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 3def132..4b659dc 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -74,14 +74,12 @@ impl Ferese { .space .outputs() .all(|output| self.session_lock.presented.contains(output)) + && let Some(confirmation) = self.session_lock.confirmation.take() + && confirmation.ext_session_lock().is_alive() { - if let Some(confirmation) = self.session_lock.confirmation.take() { - if confirmation.ext_session_lock().is_alive() { - confirmation.lock(); - self.session_lock.confirmed = true; - tracing::info!("session lock confirmed after safe output frames"); - } - } + confirmation.lock(); + self.session_lock.confirmed = true; + tracing::info!("session lock confirmed after safe output frames"); } } @@ -163,14 +161,14 @@ impl SessionLockHandler for Ferese { } fn new_surface(&mut self, surface: LockSurface, output: WlOutput) { - if let Some(output) = Output::from_resource(&output) { - if let Some(geometry) = self.space.output_geometry(&output) { - surface.with_pending_state(|state| { - state.size = Some((geometry.size.w as u32, geometry.size.h as u32).into()) - }); - self.session_lock.surfaces.insert(output, surface); - self.focus_lock_surface(); - } + if let Some(output) = Output::from_resource(&output) + && let Some(geometry) = self.space.output_geometry(&output) + { + surface.with_pending_state(|state| { + state.size = Some((geometry.size.w as u32, geometry.size.h as u32).into()) + }); + self.session_lock.surfaces.insert(output, surface); + self.focus_lock_surface(); } } } diff --git a/compositor/src/state.rs b/compositor/src/state.rs index e6014fd..01b0b82 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -24,7 +24,7 @@ use smithay::{ }, desktop::{ LayerSurface, PopupManager, Space, Window, WindowSurfaceType, layer_map_for_output, - utils::send_frames_surface_tree, + space::SpaceElement, utils::send_frames_surface_tree, }, input::{ Seat, SeatState, @@ -241,17 +241,205 @@ pub struct RuntimeConfig { } impl Ferese { + pub fn new( + event_loop: &mut EventLoop<'static, Self>, + display: Display, + config: RuntimeConfig, + ) -> Result> { + let display_handle = display.handle(); + crate::handlers::screencopy::init_global(&display_handle); + crate::effects::init_global(&display_handle); + crate::shell_control::init_global(&display_handle); + let alpha_modifier_state = AlphaModifierState::new::(&display_handle); + let compositor_state = CompositorState::new::(&display_handle); + let cursor_shape_state = CursorShapeManagerState::new::(&display_handle); + let xdg_shell_state = XdgShellState::new::(&display_handle); + let decoration_state = XdgDecorationState::new::(&display_handle); + let dmabuf_state = DmabufState::new(); + let fractional_scale_state = FractionalScaleManagerState::new::(&display_handle); + let idle_inhibit_state = IdleInhibitManagerState::new::(&display_handle); + let idle_notifier_state = IdleNotifierState::new(&display_handle, event_loop.handle()); + let session_lock_state = smithay::wayland::session_lock::SessionLockManagerState::new::< + Self, + _, + >(&display_handle, |_| true); + let input_method_enabled = + std::env::var_os("FERESE_ENABLE_INPUT_METHOD").is_some_and(|value| value == "1"); + let input_method_manager_state = + InputMethodManagerState::new::(&display_handle, move |_| input_method_enabled); + let keyboard_shortcuts_inhibit_state = + KeyboardShortcutsInhibitState::new::(&display_handle); + let shortcut_inhibit_enabled = + std::env::var_os("FERESE_ENABLE_SHORTCUT_INHIBIT").is_some_and(|value| value == "1"); + if !shortcut_inhibit_enabled { + display_handle.disable_global::(keyboard_shortcuts_inhibit_state.global()); + } + let shm_state = ShmState::new::(&display_handle, Vec::new()); + let single_pixel_buffer_state = SinglePixelBufferState::new::(&display_handle); + let text_input_manager_state = TextInputManagerState::new::(&display_handle); + let output_manager_state = OutputManagerState::new_with_xdg_output::(&display_handle); + let pointer_constraints_state = PointerConstraintsState::new::(&display_handle); + let presentation_state = + PresentationState::new::(&display_handle, libc::CLOCK_MONOTONIC as u32); + let data_device_state = DataDeviceState::new::(&display_handle); + let primary_selection_state = PrimarySelectionState::new::(&display_handle); + let relative_pointer_state = RelativePointerManagerState::new::(&display_handle); + let viewporter_state = ViewporterState::new::(&display_handle); + let layer_shell_state = WlrLayerShellState::new::(&display_handle); + let xdg_activation_state = XdgActivationState::new::(&display_handle); + let xdg_foreign_state = XdgForeignState::new::(&display_handle); + let xdg_toplevel_icon_manager = XdgToplevelIconManager::new::(&display_handle); + let mut seat_state = SeatState::new(); + let mut seat = seat_state.new_wl_seat(&display_handle, "ferese-winit"); + let xkb_options = (!config.input_settings.xkb_options.is_empty()) + .then(|| config.input_settings.xkb_options.join(",")); + seat.add_keyboard( + XkbConfig { + layout: &config.input_settings.xkb_layout, + variant: &config.input_settings.xkb_variant, + options: xkb_options, + ..XkbConfig::default() + }, + config.input_settings.repeat_delay_ms, + config.input_settings.repeat_rate, + )?; + seat.add_pointer(); + seat.add_touch(); + let socket_name = Self::init_wayland_listener(display, event_loop)?; + + let start_time = Instant::now(); + let cursor_theme = crate::cursor::cursor_theme(); + let default_cursor = crate::cursor::load_named_cursor(&cursor_theme, CursorIcon::Default); + let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]); + + let mut state = Self { + session_lock_state, + session_lock: crate::session_lock::Lock::default(), + config_source: None, + start_time, + socket_name, + display_handle, + loop_signal: event_loop.get_signal(), + space: Space::default(), + workspaces: WorkspaceSet::new( + config.layout_mode, + config.default_column_width, + config.scrolling_focus_strategy, + ), + output_workspaces: OutputWorkspaceMap::default(), + output_ids: HashMap::new(), + output_identity_ids: HashMap::new(), + window_ids: HashMap::new(), + window_geometry: HashMap::new(), + resize_transactions: HashMap::new(), + resize_snapshots: HashMap::new(), + nested_backend: None, + wallpaper: crate::wallpaper::WallpaperState::with_wakeup( + config.wallpaper, + Some(event_loop.get_signal()), + ), + maximized_windows: HashSet::new(), + maximized_column_widths: HashMap::new(), + window_stack: crate::stacking::WindowStack::default(), + natural_floating_pending: HashSet::new(), + window_borders: HashMap::new(), + window_dims: HashMap::new(), + window_resize_fills: HashMap::new(), + window_dimming: HashMap::new(), + window_focus: HashMap::new(), + window_shadows: HashMap::new(), + rounded_clip_programs: HashMap::new(), + overview_scrims: HashMap::new(), + material_programs: HashMap::new(), + material_buffers: HashMap::new(), + blur_programs: HashMap::new(), + backdrop_generation: 0, + closing_windows: HashMap::new(), + viewport_animations: HashMap::new(), + scrolling_world_x: HashMap::new(), + viewport_coupled_widths: HashMap::new(), + pending_column_width_cycles: HashSet::new(), + focused_window: None, + column_width_presets: config.column_width_presets, + gap_config: config.gap_config, + input_settings: config.input_settings, + bindings: config.bindings, + window_rules: config.window_rules, + window_rules_applied: HashSet::new(), + theme_settings: config.theme_settings, + animations_enabled: config.animations_enabled, + animation_speed: config.animation_speed, + spring_config: config.spring_config, + viewport_spring_config: config.viewport_spring_config, + output_profiles: config.output_profiles, + autostart: config.autostart, + cursor_status: CursorImageStatus::default_named(), + cursor_redraw_pending: false, + cursor_theme, + named_cursors, + intercepted_keys: HashSet::new(), + swipe: crate::gestures::Swipe::default(), + idle_inhibitors: HashMap::new(), + active_shortcuts_inhibitor: None, + direct_backend: None, + _ipc_socket: None, + pending_dmabuf_imports: Vec::new(), + pending_screencopies: Vec::new(), + shell_resources: Vec::new(), + shell_snapshot_serial: 0, + last_shell_snapshot: None, + overview: crate::overview::OverviewState::with_font_family(config.overview_font_family), + next_window_id: 1, + next_output_id: 1, + last_animation_tick: start_time, + popups: PopupManager::default(), + dismissing_popups: Vec::new(), + seat, + alpha_modifier_state, + compositor_state, + cursor_shape_state, + data_device_state, + decoration_state, + dmabuf_state, + fractional_scale_state, + idle_inhibit_state, + idle_notifier_state, + input_method_manager_state, + keyboard_shortcuts_inhibit_state, + output_manager_state, + pointer_constraints_state, + presentation_state, + primary_selection_state, + relative_pointer_state, + seat_state, + shm_state, + single_pixel_buffer_state, + text_input_manager_state, + viewporter_state, + layer_shell_state, + xdg_activation_state, + xdg_foreign_state, + xdg_shell_state, + xdg_toplevel_icon_manager, + }; + state._ipc_socket = Some(crate::ipc::init(event_loop)?); + Ok(state) + } + pub(crate) fn apply_runtime_config(&mut self, config: RuntimeConfig) -> Result<(), String> { if self.output_profiles != config.output_profiles { crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?; } + let keyboard_changed = self.input_settings.xkb_layout != config.input_settings.xkb_layout || self.input_settings.xkb_variant != config.input_settings.xkb_variant || self.input_settings.xkb_options != config.input_settings.xkb_options; + if let Some(keyboard) = self.seat.get_keyboard() { if keyboard_changed { let options = (!config.input_settings.xkb_options.is_empty()) .then(|| config.input_settings.xkb_options.join(",")); + keyboard .set_xkb_config( self, @@ -269,7 +457,9 @@ impl Ferese { config.input_settings.repeat_delay_ms, ); } + self.advance_animations(Instant::now()); + let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad; let outputs_changed = self.output_profiles != config.output_profiles; let bounds = self @@ -287,6 +477,7 @@ impl Ferese { (workspace.id, rect) }) .collect(); + self.workspaces .reconfigure_live( config.layout_mode, @@ -307,20 +498,25 @@ impl Ferese { self.viewport_spring_config = config.viewport_spring_config; self.output_profiles = config.output_profiles; self.autostart = config.autostart; + if touchpad_changed { crate::backends::direct::reload_input_devices(self); } + if outputs_changed { crate::backends::direct::reload_outputs(self); } + if old_rules != self.window_rules { self.reapply_window_rules(&old_rules); } + self.overview.set_font_family(config.overview_font_family); self.wallpaper.reload(config.wallpaper); self.backdrop_generation = self.backdrop_generation.wrapping_add(1); self.relayout(); crate::backends::direct::render_all(self); + Ok(()) } @@ -509,191 +705,6 @@ impl Ferese { } } - pub fn new( - event_loop: &mut EventLoop<'static, Self>, - display: Display, - config: RuntimeConfig, - ) -> Result> { - let display_handle = display.handle(); - crate::handlers::screencopy::init_global(&display_handle); - crate::effects::init_global(&display_handle); - crate::shell_control::init_global(&display_handle); - let alpha_modifier_state = AlphaModifierState::new::(&display_handle); - let compositor_state = CompositorState::new::(&display_handle); - let cursor_shape_state = CursorShapeManagerState::new::(&display_handle); - let xdg_shell_state = XdgShellState::new::(&display_handle); - let decoration_state = XdgDecorationState::new::(&display_handle); - let dmabuf_state = DmabufState::new(); - let fractional_scale_state = FractionalScaleManagerState::new::(&display_handle); - let idle_inhibit_state = IdleInhibitManagerState::new::(&display_handle); - let idle_notifier_state = IdleNotifierState::new(&display_handle, event_loop.handle()); - let session_lock_state = smithay::wayland::session_lock::SessionLockManagerState::new::< - Self, - _, - >(&display_handle, |_| true); - let input_method_enabled = - std::env::var_os("FERESE_ENABLE_INPUT_METHOD").is_some_and(|value| value == "1"); - let input_method_manager_state = - InputMethodManagerState::new::(&display_handle, move |_| input_method_enabled); - let keyboard_shortcuts_inhibit_state = - KeyboardShortcutsInhibitState::new::(&display_handle); - let shortcut_inhibit_enabled = - std::env::var_os("FERESE_ENABLE_SHORTCUT_INHIBIT").is_some_and(|value| value == "1"); - if !shortcut_inhibit_enabled { - display_handle.disable_global::(keyboard_shortcuts_inhibit_state.global()); - } - let shm_state = ShmState::new::(&display_handle, Vec::new()); - let single_pixel_buffer_state = SinglePixelBufferState::new::(&display_handle); - let text_input_manager_state = TextInputManagerState::new::(&display_handle); - let output_manager_state = OutputManagerState::new_with_xdg_output::(&display_handle); - let pointer_constraints_state = PointerConstraintsState::new::(&display_handle); - let presentation_state = - PresentationState::new::(&display_handle, libc::CLOCK_MONOTONIC as u32); - let data_device_state = DataDeviceState::new::(&display_handle); - let primary_selection_state = PrimarySelectionState::new::(&display_handle); - let relative_pointer_state = RelativePointerManagerState::new::(&display_handle); - let viewporter_state = ViewporterState::new::(&display_handle); - let layer_shell_state = WlrLayerShellState::new::(&display_handle); - let xdg_activation_state = XdgActivationState::new::(&display_handle); - let xdg_foreign_state = XdgForeignState::new::(&display_handle); - let xdg_toplevel_icon_manager = XdgToplevelIconManager::new::(&display_handle); - let mut seat_state = SeatState::new(); - let mut seat = seat_state.new_wl_seat(&display_handle, "ferese-winit"); - let xkb_options = (!config.input_settings.xkb_options.is_empty()) - .then(|| config.input_settings.xkb_options.join(",")); - seat.add_keyboard( - XkbConfig { - layout: &config.input_settings.xkb_layout, - variant: &config.input_settings.xkb_variant, - options: xkb_options, - ..XkbConfig::default() - }, - config.input_settings.repeat_delay_ms, - config.input_settings.repeat_rate, - )?; - seat.add_pointer(); - seat.add_touch(); - let socket_name = Self::init_wayland_listener(display, event_loop)?; - - let start_time = Instant::now(); - let cursor_theme = crate::cursor::cursor_theme(); - let default_cursor = crate::cursor::load_named_cursor(&cursor_theme, CursorIcon::Default); - let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]); - - let mut state = Self { - session_lock_state, - session_lock: crate::session_lock::Lock::default(), - config_source: None, - start_time, - socket_name, - display_handle, - loop_signal: event_loop.get_signal(), - space: Space::default(), - workspaces: WorkspaceSet::new( - config.layout_mode, - config.default_column_width, - config.scrolling_focus_strategy, - ), - output_workspaces: OutputWorkspaceMap::default(), - output_ids: HashMap::new(), - output_identity_ids: HashMap::new(), - window_ids: HashMap::new(), - window_geometry: HashMap::new(), - resize_transactions: HashMap::new(), - resize_snapshots: HashMap::new(), - nested_backend: None, - wallpaper: crate::wallpaper::WallpaperState::with_wakeup( - config.wallpaper, - Some(event_loop.get_signal()), - ), - maximized_windows: HashSet::new(), - maximized_column_widths: HashMap::new(), - window_stack: crate::stacking::WindowStack::default(), - natural_floating_pending: HashSet::new(), - window_borders: HashMap::new(), - window_dims: HashMap::new(), - window_resize_fills: HashMap::new(), - window_dimming: HashMap::new(), - window_focus: HashMap::new(), - window_shadows: HashMap::new(), - rounded_clip_programs: HashMap::new(), - overview_scrims: HashMap::new(), - material_programs: HashMap::new(), - material_buffers: HashMap::new(), - blur_programs: HashMap::new(), - backdrop_generation: 0, - closing_windows: HashMap::new(), - viewport_animations: HashMap::new(), - scrolling_world_x: HashMap::new(), - viewport_coupled_widths: HashMap::new(), - pending_column_width_cycles: HashSet::new(), - focused_window: None, - column_width_presets: config.column_width_presets, - gap_config: config.gap_config, - input_settings: config.input_settings, - bindings: config.bindings, - window_rules: config.window_rules, - window_rules_applied: HashSet::new(), - theme_settings: config.theme_settings, - animations_enabled: config.animations_enabled, - animation_speed: config.animation_speed, - spring_config: config.spring_config, - viewport_spring_config: config.viewport_spring_config, - output_profiles: config.output_profiles, - autostart: config.autostart, - cursor_status: CursorImageStatus::default_named(), - cursor_redraw_pending: false, - cursor_theme, - named_cursors, - intercepted_keys: HashSet::new(), - swipe: crate::gestures::Swipe::default(), - idle_inhibitors: HashMap::new(), - active_shortcuts_inhibitor: None, - direct_backend: None, - _ipc_socket: None, - pending_dmabuf_imports: Vec::new(), - pending_screencopies: Vec::new(), - shell_resources: Vec::new(), - shell_snapshot_serial: 0, - last_shell_snapshot: None, - overview: crate::overview::OverviewState::with_font_family(config.overview_font_family), - next_window_id: 1, - next_output_id: 1, - last_animation_tick: start_time, - popups: PopupManager::default(), - dismissing_popups: Vec::new(), - seat, - alpha_modifier_state, - compositor_state, - cursor_shape_state, - data_device_state, - decoration_state, - dmabuf_state, - fractional_scale_state, - idle_inhibit_state, - idle_notifier_state, - input_method_manager_state, - keyboard_shortcuts_inhibit_state, - output_manager_state, - pointer_constraints_state, - presentation_state, - primary_selection_state, - relative_pointer_state, - seat_state, - shm_state, - single_pixel_buffer_state, - text_input_manager_state, - viewporter_state, - layer_shell_state, - xdg_activation_state, - xdg_foreign_state, - xdg_shell_state, - xdg_toplevel_icon_manager, - }; - state._ipc_socket = Some(crate::ipc::init(event_loop)?); - Ok(state) - } - fn init_wayland_listener( display: Display, event_loop: &mut EventLoop<'static, Self>, @@ -836,35 +847,38 @@ impl Ferese { } pub fn window_under_visual(&self, position: Point) -> Option { + let overview_active = self.overview.is_active(); let workspace = self.workspace_under_pointer(position)?; - let mut candidates = if self.overview.is_active() { - self.window_ids.keys().cloned().collect::>() - } else { - self.space.elements().rev().cloned().collect::>() - }; - if self.overview.is_active() { - // Match Overview's front-to-back render order during overlapping motion. - candidates.sort_by_key(|window| { - std::cmp::Reverse(self.window_ids.get(window).map_or(0, |id| id.0)) - }); - } - candidates.iter().find_map(|window| { + + let hit = |window: &Window| -> Option { if !self.window_content_ready(window) { return None; } + let id = self.window_ids.get(window)?; if self.workspaces.workspace_for_window(*id) != Some(workspace) { return None; } - let visual = self.presented_window_rect(*id)?; - let caption_height = if self.overview.is_active() { 34.0 } else { 0.0 }; + let visual = self.presented_window_rect(*id)?; + let caption_height = if overview_active { 34.0 } else { 0.0 }; (position.x >= visual.x && position.y >= visual.y && position.x < visual.x + visual.width && position.y < visual.y + visual.height + caption_height) - .then(|| window.clone()) - }) + .then_some(window.clone()) + }; + + if overview_active { + let mut candidates = self.window_ids.keys().cloned().collect::>(); + // Match Overview's front-to-back render order during overlapping motion. + candidates.sort_by_key(|window| { + std::cmp::Reverse(self.window_ids.get(window).map_or(0, |id| id.0)) + }); + candidates.iter().find_map(hit) + } else { + self.space.elements().rev().find_map(hit) + } } fn workspace_under_pointer(&self, position: Point) -> Option { @@ -909,6 +923,9 @@ impl Ferese { id.map_or(usize::MAX, |id| self.window_stack.rank(id)), ) }); + if stacking_order_settled(self.space.elements(), &windows, |window| window.z_index()) { + return; + } for window in windows { self.space.raise_element(&window, false); } @@ -1571,18 +1588,17 @@ impl Ferese { fullscreen_changed || maximized_changed || tiled_changed || decoration_changed }); - if natural_pending || requested_size.is_some() || state_changed { - if let Some(serial) = toplevel.send_pending_configure() - && requested_size.is_some() - && had_geometry - && self.animations_enabled - { - self.resize_transactions.insert( - id, - crate::resize_transaction::ResizeTransaction::new(serial, now) - .with_source_geometry(window.geometry()), - ); - } + if (natural_pending || requested_size.is_some() || state_changed) + && let Some(serial) = toplevel.send_pending_configure() + && requested_size.is_some() + && had_geometry + && self.animations_enabled + { + self.resize_transactions.insert( + id, + crate::resize_transaction::ResizeTransaction::new(serial, now) + .with_source_geometry(window.geometry()), + ); } } } @@ -2341,22 +2357,22 @@ impl Ferese { } if self.workspaces.placement(window) == Some(WindowPlacement::Tiled) { let workspace_id = self.workspaces.workspace_for_window(window).unwrap(); - if let Some(workspace) = self.workspaces.workspace_mut(workspace_id) { - if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout { - if enabled { - if let Some(column) = layout - .columns() - .iter() - .find(|column| column.windows.contains(&window)) - { - self.maximized_column_widths - .entry(window) - .or_insert(column.width); - } - let _ = layout.set_column_width(window, ColumnWidth::Proportion(1.0)); - } else if let Some(width) = self.maximized_column_widths.remove(&window) { - let _ = layout.set_column_width(window, width); + if let Some(workspace) = self.workspaces.workspace_mut(workspace_id) + && let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout + { + if enabled { + if let Some(column) = layout + .columns() + .iter() + .find(|column| column.windows.contains(&window)) + { + self.maximized_column_widths + .entry(window) + .or_insert(column.width); } + let _ = layout.set_column_width(window, ColumnWidth::Proportion(1.0)); + } else if let Some(width) = self.maximized_column_widths.remove(&window) { + let _ = layout.set_column_width(window, width); } } } @@ -2870,6 +2886,18 @@ fn window_has_buffer(window: &Window) -> bool { }) } +fn stacking_order_settled<'a, T, I>(current: I, target: &'a [T], z_index: impl Fn(&T) -> u8) -> bool +where + I: IntoIterator, + T: PartialEq, +{ + let Some((first, rest)) = target.split_first() else { + return current.into_iter().next().is_none(); + }; + let expected = z_index(first); + rest.iter().all(|window| z_index(window) == expected) && current.into_iter().eq(target) +} + #[derive(Default)] pub struct ClientState { pub compositor_state: CompositorClientState, @@ -3016,4 +3044,49 @@ mod tests { 1_000.0 ); } + + #[derive(Clone, Copy, PartialEq, Debug)] + struct StackedWindow { + id: u32, + z_index: u8, + } + + fn stacked(id: u32, z_index: u8) -> StackedWindow { + StackedWindow { id, z_index } + } + + #[test] + fn stacking_is_settled_when_order_matches_and_z_index_is_uniform() { + let current = [stacked(1, 30), stacked(2, 30), stacked(3, 30)]; + assert!(stacking_order_settled(current.iter(), ¤t, |window| { + window.z_index + })); + } + + #[test] + fn stacking_is_not_settled_when_order_differs() { + let current = [stacked(2, 30), stacked(1, 30)]; + let target = [stacked(1, 30), stacked(2, 30)]; + assert!(!stacking_order_settled(current.iter(), &target, |window| { + window.z_index + })); + } + + #[test] + fn stacking_is_not_settled_when_z_index_is_mixed_even_with_matching_order() { + let current = [stacked(1, 30), stacked(2, 40)]; + assert!(!stacking_order_settled( + current.iter(), + ¤t, + |window| window.z_index + )); + } + + #[test] + fn stacking_is_settled_for_an_empty_space() { + let empty: [StackedWindow; 0] = []; + assert!(stacking_order_settled(empty.iter(), &empty, |window| { + window.z_index + })); + } } diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 4ce14df..fbfe28f 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -1017,7 +1017,7 @@ pub(crate) fn frame_effect_metrics( _elements: &[AnimatedWindowRenderElement], _scale: f64, ) -> FrameEffectMetrics { - FrameEffectMetrics::default() + FrameEffectMetrics } fn output_elements( @@ -1220,45 +1220,44 @@ fn output_elements( output, &programs, ); - if !scale_content { - if let Some(snapshot) = state.resize_snapshots.get(&id) - && snapshot.context == renderer.context_id().erased() - && (snapshot.scale - scale).abs() < 0.001 - { - let size = snapshot.texture.size(); - let visible = - Rectangle::new(pixels.loc, (size.w, size.h).into()).intersection(pixels); - if let Some(visible) = visible { - let clip = framebuffer_clip_rect( - pixels, - output.current_mode().unwrap().size, - output.current_transform().invert(), - ); - elements.push( - NativeTextureElement { - id: snapshot.id.clone(), - commit: snapshot.commit, - texture: snapshot.texture.clone(), - geometry: visible, - source: Rectangle::from_size(Size::from(( - f64::from(visible.size.w), - f64::from(visible.size.h), - ))), - alpha: crate::presentation::handoff_alpha(snapshot.elapsed) - * close_alpha, - program: Some(programs.texture.clone()), - uniforms: vec![ - Uniform::new("clip_rect", clip).into_owned(), - Uniform::new( - "radius", - scaled_effect_value(window_radius, constrain, scale), - ) - .into_owned(), - ], - } - .into(), - ); - } + if !scale_content + && let Some(snapshot) = state.resize_snapshots.get(&id) + && snapshot.context == renderer.context_id().erased() + && (snapshot.scale - scale).abs() < 0.001 + { + let size = snapshot.texture.size(); + let visible = + Rectangle::new(pixels.loc, (size.w, size.h).into()).intersection(pixels); + if let Some(visible) = visible { + let clip = framebuffer_clip_rect( + pixels, + output.current_mode().unwrap().size, + output.current_transform().invert(), + ); + elements.push( + NativeTextureElement { + id: snapshot.id.clone(), + commit: snapshot.commit, + texture: snapshot.texture.clone(), + geometry: visible, + source: Rectangle::from_size(Size::from(( + f64::from(visible.size.w), + f64::from(visible.size.h), + ))), + alpha: crate::presentation::handoff_alpha(snapshot.elapsed) + * close_alpha, + program: Some(programs.texture.clone()), + uniforms: vec![ + Uniform::new("clip_rect", clip).into_owned(), + Uniform::new( + "radius", + scaled_effect_value(window_radius, constrain, scale), + ) + .into_owned(), + ], + } + .into(), + ); } } elements.extend(rounded_window_elements( diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs index 9b574b5..74bfb9c 100644 --- a/crates/ferese-config/src/lib.rs +++ b/crates/ferese-config/src/lib.rs @@ -39,6 +39,7 @@ pub fn default_wallpaper() -> &'static str { .unwrap_or_else(|| { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/wallpapers/ferese.png") }); + path.canonicalize() .unwrap_or(path) .to_string_lossy() @@ -50,6 +51,7 @@ fn field(name: &str, parent: &str) -> String { if parent == "commands" { return name.to_owned(); } + match name { "binding" => "bindings".into(), "window-rule" => "window_rules".into(), @@ -61,11 +63,14 @@ fn field(name: &str, parent: &str) -> String { } fn is_records(key: &str, parent: &str) -> bool { - match (parent, key) { - ("", "bindings" | "window_rules" | "output_profiles" | "autostart") => true, - ("output_profiles", "outputs") | ("desktop_widgets", "notes") => true, - _ => false, - } + matches!( + (parent, key), + ( + "", + "bindings" | "window_rules" | "output_profiles" | "autostart" + ) | ("output_profiles", "outputs") + | ("desktop_widgets", "notes") + ) } fn is_array(key: &str, parent: &str) -> bool { @@ -365,11 +370,11 @@ fn restore_node_formats(doc: &mut KdlDocument, formats: &mut impl Iterator } let node = &mut doc.nodes_mut()[i]; - if parts.len() == 2 { - if let Some(entry) = node + if parts.len() == 2 + && let Some(entry) = node .entries_mut() .iter_mut() .find(|e| e.name().is_some_and(|n| field(n.value(), key) == parts[1])) - { - set_entry_value(entry, kdl_value(&value)?); - return Ok(()); - } + { + set_entry_value(entry, kdl_value(&value)?); + return Ok(()); } set_in( diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index 423df48..2588955 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -456,7 +456,10 @@ impl ScrollingLayout { if let Some(focused) = focused { self.focus(focused)?; } - self.validate()?; + + self.validate_scalars()?; + debug_assert!(self.validate().is_ok()); + if self.columns.is_empty() { self.viewport_x = 0.0; return Ok(LayoutResult::default()); @@ -673,7 +676,7 @@ impl ScrollingLayout { }) } - pub fn validate(&self) -> Result<(), LayoutError> { + fn validate_scalars(&self) -> Result<(), LayoutError> { if !self.viewport_x.is_finite() { return Err(LayoutError::InvalidTree( "scrolling viewport offset is invalid", @@ -701,6 +704,11 @@ impl ScrollingLayout { "scrolling active column is invalid", )); } + Ok(()) + } + + pub fn validate(&self) -> Result<(), LayoutError> { + self.validate_scalars()?; let mut windows = HashSet::new(); for column in &self.columns { diff --git a/shell/ferese-lock/src/appearance.rs b/shell/ferese-lock/src/appearance.rs index bc34c87..8d954f1 100644 --- a/shell/ferese-lock/src/appearance.rs +++ b/shell/ferese-lock/src/appearance.rs @@ -109,12 +109,14 @@ impl Appearance { } else { ThemeBuilder::dark() }; - let mut corners = cosmic::cosmic_theme::CornerRadii::default(); - corners.radius_xs = [self.radius.min(4.); 4]; - corners.radius_s = [self.radius.min(8.); 4]; - corners.radius_m = [self.radius; 4]; - corners.radius_l = [self.radius; 4]; - corners.radius_xl = [self.radius; 4]; + let corners = cosmic::cosmic_theme::CornerRadii { + radius_0: Default::default(), + radius_xs: [self.radius.min(4.); 4], + radius_s: [self.radius.min(8.); 4], + radius_m: [self.radius; 4], + radius_l: [self.radius; 4], + radius_xl: [self.radius; 4], + }; let mut native = builder .corner_radii(corners) .bg_color(color(self.panel)) diff --git a/shell/ferese-lock/src/main.rs b/shell/ferese-lock/src/main.rs index 2fcd12d..f585a3c 100644 --- a/shell/ferese-lock/src/main.rs +++ b/shell/ferese-lock/src/main.rs @@ -1,3 +1,5 @@ +#![feature(box_patterns)] + mod appearance; mod auth; mod runtime; @@ -36,19 +38,23 @@ fn main() -> iced::Result { } else { ferese_config::config_path() }; + if !args.is_empty() && !preview && args != ["--foreground"] { eprintln!("Usage: ferese-lock [--preview | --foreground]"); std::process::exit(2); } + if !preview && !auth::available() { eprintln!("ferese-lock: install the ferese-lock PAM policy before locking"); std::process::exit(1); } + if !preview { if let Err(error) = runtime::check_protocol() { eprintln!("ferese-lock: {error}"); std::process::exit(1); } + if args.is_empty() { if let Err(error) = runtime::daemonize() { eprintln!("ferese-lock: {error}"); @@ -57,14 +63,17 @@ fn main() -> iced::Result { return Ok(()); } } + let user = auth::username().unwrap_or_else(|error| { eprintln!("ferese-lock: {error}"); std::process::exit(1) }); + // Password-bearing UI processes must not create core dumps. unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0); } + let appearance = appearance::Appearance::load(&user, config.as_deref()); cosmic::app::run::( Settings::default() @@ -81,18 +90,20 @@ fn main() -> iced::Result { #[derive(Clone)] enum Message { - Event(Event), + Event(Box), Input(String), Submit, Authenticated(bool), Tick, } + // Never derive Debug for password-bearing messages. impl std::fmt::Debug for Message { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("LockerMessage") } } + #[derive(Default, Debug, PartialEq)] enum AuthState { #[default] @@ -101,10 +112,12 @@ enum AuthState { Rejected(Instant), Unlocking, } + impl AuthState { fn may_submit(&self) -> bool { matches!(self, Self::Ready) } + fn complete(&mut self, success: bool) -> bool { if *self != Self::Checking { return false; @@ -117,6 +130,7 @@ impl AuthState { success } } + struct Locker { core: Core, preview: bool, @@ -132,17 +146,22 @@ struct Locker { confirmation: runtime::Confirmation, caps_lock: bool, } + impl cosmic::Application for Locker { type Executor = cosmic::executor::Default; type Flags = (bool, String, appearance::Appearance); type Message = Message; + const APP_ID: &'static str = "dev.ferese.Lock"; + fn core(&self) -> &Core { &self.core } + fn core_mut(&mut self) -> &mut Core { &mut self.core } + fn init(mut core: Core, (preview, user, appearance): Self::Flags) -> (Self, Task) { core.window.show_headerbar = false; let now = jiff::Zoned::now(); @@ -174,6 +193,7 @@ impl cosmic::Application for Locker { }, ) } + fn subscription(&self) -> Subscription { Subscription::batch([ event::listen_with(|event, _, _| match event { @@ -182,22 +202,26 @@ impl cosmic::Application for Locker { )) | Event::Window(window::Event::Opened { .. }) | Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => { - Some(Message::Event(event)) + Some(Message::Event(Box::new(event))) } _ => None, }), iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick), ]) } + fn update(&mut self, message: Message) -> Task { match message { - Message::Event(Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers))) => { + Message::Event(box Event::Keyboard(iced::keyboard::Event::ModifiersChanged( + modifiers, + ))) => { self.caps_lock = modifiers.contains(iced::keyboard::Modifiers::CAPS_LOCK); } Message::Tick => { let now = jiff::Zoned::now(); self.clock = now.strftime(self.appearance.clock_format()).to_string(); self.date = now.strftime("%A, %B %-d").to_string(); + if matches!(self.state, AuthState::Rejected(at) if at.elapsed() >= Duration::from_secs(2)) { self.state = AuthState::Ready; @@ -217,12 +241,14 @@ impl cosmic::Application for Locker { self.status = "Preview only · your session is not locked"; return focus(); } + if !self.confirmation.confirmed() || !self.state.may_submit() || self.password.is_empty() { return Task::none(); } + self.state = AuthState::Checking; self.status = "Checking password…"; let password = Zeroizing::new(std::mem::take(&mut *self.password)); @@ -243,7 +269,7 @@ impl cosmic::Application for Locker { self.status = "Password not accepted. Try again."; return focus(); } - Message::Event(Event::PlatformSpecific(PlatformSpecific::Wayland( + Message::Event(box Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::Output(event, output), ))) if !self.preview => { if matches!(event, wayland::OutputEvent::Removed) { @@ -258,7 +284,7 @@ impl cosmic::Application for Locker { } } } - Message::Event(Event::PlatformSpecific(PlatformSpecific::Wayland( + Message::Event(box Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::SessionLock(event), ))) if !self.preview => match event { wayland::SessionLockEvent::Locked => { @@ -285,25 +311,32 @@ impl cosmic::Application for Locker { } _ => {} }, - Message::Event(Event::Window(window::Event::Opened { .. })) => return focus(), + Message::Event(box Event::Window(window::Event::Opened { .. })) => { + return focus(); + } _ => {} } Task::none() } + fn on_escape(&mut self) -> Task { self.password.zeroize(); focus() } + fn view(&self) -> Element<'_, Message> { self.screen() } + fn view_window(&self, _: window::Id) -> Element<'_, Message> { self.screen() } } + fn focus() -> Task { widget::text_input::focus(widget::Id::new("password")) } + impl Locker { fn label<'a>( &self, @@ -312,9 +345,11 @@ impl Locker { ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { widget::text(label).font(self.appearance.font).size(size) } + fn screen(&self) -> Element<'_, Message> { iced::widget::responsive(move |size| self.screen_content(size)).into() } + fn screen_content(&self, size: iced::Size) -> Element<'_, Message> { let avatar_size = (size.height * 0.09).clamp(64., 112.); let clock_size = (size.height * 0.165) @@ -336,12 +371,14 @@ impl Locker { .style(password_style(a.radius, a.accent, a.panel)) .font(a.font) .id(widget::Id::new("password")); + if self.state.may_submit() { input = input.on_input(Message::Input).on_submit(|mut value| { value.zeroize(); Message::Submit }); } + let submit = widget::button::custom(widget::icon::from_name("go-next-symbolic").size(20)) .class(theme::Button::Icon) .padding(6) @@ -401,6 +438,7 @@ impl Locker { .min((size.width - margin * 2.).max(160.)), ); let mut clock = column([]).spacing(0).align_x(iced::Alignment::Center); + if a.show_clock { clock = clock.push( self.label(&self.clock, clock_size) @@ -411,12 +449,14 @@ impl Locker { .class(iced::Color::WHITE), ); } + if a.show_date { clock = clock.push( self.label(&self.date, 18) .class(iced::Color::WHITE.scale_alpha(0.72)), ); } + let clock_height = if a.show_clock { clock_size as f32 * 1.3 } else { @@ -472,26 +512,6 @@ impl Locker { iced::widget::stack![background, foreground].into() } } -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn only_an_active_successful_attempt_unlocks() { - let mut state = AuthState::Ready; - assert!(!state.complete(true)); - state = AuthState::Checking; - assert!(!state.complete(false)); - assert!(!state.may_submit()); - assert!(!state.complete(true)); - state = AuthState::Checking; - assert!(state.complete(true)); - assert!(!state.complete(true)); - } - #[test] - fn password_messages_are_redacted() { - assert!(!format!("{:?}", Message::Input("secret".into())).contains("secret")); - } -} fn line_icon(path: &str, size: u16) -> widget::icon::Icon { let svg = format!( @@ -499,6 +519,7 @@ fn line_icon(path: &str, size: u16) -> widget::icon::Icon { ); widget::icon(widget::icon::from_svg_bytes(svg.into_bytes()).symbolic(true)).size(size) } + fn ferese_symbol() -> widget::icon::Icon { widget::icon( widget::icon::from_svg_bytes( @@ -533,6 +554,7 @@ fn password_style(radius: f32, accent: iced::Color, surface: iced::Color) -> the selected_fill: accent.scale_alpha(0.5), label_color: iced::Color::WHITE, }; + theme::TextInput::Custom { active: Box::new(move |_| appearance(false)), hovered: Box::new(move |_| appearance(true)), @@ -541,3 +563,24 @@ fn password_style(radius: f32, accent: iced::Color, surface: iced::Color) -> the disabled: Box::new(move |_| appearance(false)), } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_an_active_successful_attempt_unlocks() { + let mut state = AuthState::Ready; + assert!(!state.complete(true)); + state = AuthState::Checking; + assert!(!state.complete(false)); + assert!(!state.may_submit()); + assert!(!state.complete(true)); + state = AuthState::Checking; + assert!(state.complete(true)); + assert!(!state.complete(true)); + } + #[test] + fn password_messages_are_redacted() { + assert!(!format!("{:?}", Message::Input("secret".into())).contains("secret")); + } +} diff --git a/shell/ferese-polkit-agent/src/prompt.rs b/shell/ferese-polkit-agent/src/prompt.rs index 20f3c8e..dffa11d 100644 --- a/shell/ferese-polkit-agent/src/prompt.rs +++ b/shell/ferese-polkit-agent/src/prompt.rs @@ -82,13 +82,14 @@ impl Appearance { } else { ThemeBuilder::dark() }; - - let mut corners = cosmic::cosmic_theme::CornerRadii::default(); - corners.radius_xs = [self.radius.min(4.); 4]; - corners.radius_s = [self.radius.min(8.); 4]; - corners.radius_m = [self.radius; 4]; - corners.radius_l = [self.radius; 4]; - corners.radius_xl = [self.radius; 4]; + let corners = cosmic::cosmic_theme::CornerRadii { + radius_xs: [self.radius.min(4.); 4], + radius_s: [self.radius.min(8.); 4], + radius_m: [self.radius; 4], + radius_l: [self.radius; 4], + radius_xl: [self.radius; 4], + radius_0: Default::default(), + }; let mut native = builder .corner_radii(corners) diff --git a/shell/ferese-polkit-agent/src/session.rs b/shell/ferese-polkit-agent/src/session.rs index dfceb13..82e4947 100644 --- a/shell/ferese-polkit-agent/src/session.rs +++ b/shell/ferese-polkit-agent/src/session.rs @@ -53,6 +53,7 @@ impl Api { let cancel = *agent .get(b"polkit_agent_session_cancel\0") .map_err(|e| e.to_string())?; + Ok(Self { _agent: agent, _polkit: polkit, @@ -199,7 +200,10 @@ fn run_session( connect( session, c"request", - Some(std::mem::transmute( + Some(std::mem::transmute::< + unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void), + unsafe extern "C" fn(), + >( request as unsafe extern "C" fn(Object, *const c_char, c_int, *mut c_void), )), signal_data, @@ -207,7 +211,10 @@ fn run_session( connect( session, c"show-info", - Some(std::mem::transmute( + Some(std::mem::transmute::< + unsafe extern "C" fn(Object, *const c_char, *mut c_void), + unsafe extern "C" fn(), + >( info as unsafe extern "C" fn(Object, *const c_char, *mut c_void), )), signal_data, @@ -215,7 +222,10 @@ fn run_session( connect( session, c"show-error", - Some(std::mem::transmute( + Some(std::mem::transmute::< + unsafe extern "C" fn(Object, *const c_char, *mut c_void), + unsafe extern "C" fn(), + >( error as unsafe extern "C" fn(Object, *const c_char, *mut c_void), )), signal_data, @@ -223,11 +233,15 @@ fn run_session( connect( session, c"completed", - Some(std::mem::transmute( + Some(std::mem::transmute::< + unsafe extern "C" fn(Object, c_int, *mut c_void), + unsafe extern "C" fn(), + >( completed as unsafe extern "C" fn(Object, c_int, *mut c_void), )), signal_data, ); + (api.initiate)(session); } diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index f041ed5..84146e7 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -274,10 +274,8 @@ impl cosmic::Application for App { |result| cosmic::Action::App(Message::LockPreviewStarted(result)), ); } - Message::LockPreviewStarted(result) => { - if let Err(error) = result { - self.error = Some(error); - } + Message::LockPreviewStarted(Err(error)) => { + self.error = Some(error); } Message::Page(page) => { self.page = page; @@ -1286,16 +1284,16 @@ impl App { // When a gradient exists, changing Accent also changes its // leading stop; otherwise the visible focus border would stay // on the old palette despite the control saying it changed. - if let Edit::Set(path, value) = &edit { - if path == "theme.colors.accent" - && self.draft.item("theme.focus_ring.gradient").is_some() - { - let gradient = set("theme.focus_ring.gradient.from", value.clone()); - if self.draft.edit(&gradient).is_ok() { - self.pending.push(gradient); - } + if let Edit::Set(path, value) = &edit + && path == "theme.colors.accent" + && self.draft.item("theme.focus_ring.gradient").is_some() + { + let gradient = set("theme.focus_ring.gradient.from", value.clone()); + if self.draft.edit(&gradient).is_ok() { + self.pending.push(gradient); } } + self.pending.push(edit); self.error = None; self.flush() diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index 9717d39..ae15eda 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -451,7 +451,12 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> { let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65; let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65; let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" { - snapshot.number("theme.material.opacity", ferese_config::DEFAULT_MATERIAL_OPACITY).clamp(0., 1.) + snapshot + .number( + "theme.material.opacity", + ferese_config::DEFAULT_MATERIAL_OPACITY, + ) + .clamp(0., 1.) } else { 1. }; @@ -556,11 +561,12 @@ mod tests { fn default_uses_logo_blue_and_native_controls_follow_lightness() { let logo = include_str!("../../../packaging/icons/ferese.svg").to_ascii_uppercase(); assert!(logo.contains(PRESETS[0].accent)); - for index in 0..PRESETS.len() { + + for (index, item) in PRESETS.iter().enumerate() { let snapshot = configured_preset(index); assert_eq!( native_theme(Some(&snapshot)).cosmic().is_dark, - PRESETS[index].name != "Ayu Light" + item.name != "Ayu Light" ); } } diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 384a897..a1e5460 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -8,14 +8,6 @@ mod recording; mod status; mod status_ui; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{Duration, Instant}; - -// Older libcosmic backends expose native surfaces in frame events. Keep -// that compatibility path gated; current backends use Window::Opened and -// window::run to retrieve handles without updates at the display refresh rate. -static EFFECT_FRAME_PENDING: AtomicBool = AtomicBool::new(true); - use cosmic::Element; use cosmic::app::{Core, Settings, Task}; use cosmic::iced::alignment; @@ -37,6 +29,8 @@ use ferese_protocols::effects::v1::client::{ ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1}, }; use jiff::Zoned; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; use wayland_client::{ Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop, globals::{GlobalListContents, registry_queue_init}, @@ -48,6 +42,10 @@ use crate::control::{ShellControl, ShellSnapshot}; const APP_ID: &str = "dev.ferese.Shell"; +// Older libcosmic backends expose native surfaces in frame events. Keep +// that compatibility path gated; current backends use Window::Opened and +// window::run to retrieve handles without updates at the display refresh rate. +static EFFECT_FRAME_PENDING: AtomicBool = AtomicBool::new(true); static SHELL_FONT: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -117,10 +115,11 @@ fn stacked_clock_digits(label: &str) -> Option<[String; 2]> { let mut parts = label.split_whitespace(); let time = parts.next()?; - if let Some(period) = parts.next() { - if !period.eq_ignore_ascii_case("am") && !period.eq_ignore_ascii_case("pm") { - return None; - } + if let Some(period) = parts.next() + && !period.eq_ignore_ascii_case("am") + && !period.eq_ignore_ascii_case("pm") + { + return None; } if parts.next().is_some() { @@ -650,8 +649,7 @@ impl cosmic::Application for FereseShell { let open = self.open_menu(kind, anchor); Task::batch([destroy, open]) } else { - let open = self.open_menu(kind, anchor); - open + self.open_menu(kind, anchor) } } Message::ConfirmPower(action) => { @@ -680,10 +678,11 @@ impl cosmic::Application for FereseShell { .status_service .send(status::Action::PowerProfile(profile)) .err(); - if self.status_error.is_none() { - if let Some(profiles) = &mut self.status.power_profiles { - profiles.active = profile.to_owned(); - } + + if self.status_error.is_none() + && let Some(profiles) = &mut self.status.power_profiles + { + profiles.active = profile.to_owned(); } return Task::none(); } @@ -706,22 +705,27 @@ impl cosmic::Application for FereseShell { _ => {} } } + self.status_error = self.status_service.send(action.clone()).err(); + if self.status_error.is_none() { self.optimistic_status(&action); } + if matches!( action, status::Action::Notifications | status::Action::Settings ) { return self.close_menu(); } + Task::none() } Message::Event(event, id) => self.handle_event(event, id), Message::Tick => { self.recorder.poll(); self.clock = current_time(); + if self.config.desktop_widgets.clock.enabled { self.desktop_clock = self .config @@ -730,6 +734,7 @@ impl cosmic::Application for FereseShell { .labels(&Zoned::now()) .unwrap_or_default(); } + let mut reload_task = Task::none(); if let Some(control) = &self.control { let poll = control.poll(); @@ -737,15 +742,19 @@ impl cosmic::Application for FereseShell { if poll.disconnected { return cosmic::iced::exit(); } + if let Some(active) = poll.overview_active { self.overview_active = active; } + if let Some(source) = poll.config { reload_task = self.reload_config(source); } + if let Some(snapshot) = poll.snapshot { self.snapshot = snapshot; let mut tasks = vec![reload_task]; + for entry in &mut self.outputs { let output = self .snapshot @@ -754,16 +763,16 @@ impl cosmic::Application for FereseShell { .find(|output| Some(output.name.as_str()) == entry.name.as_deref()) .map(|output| output.id); let hidden = output_bar_hidden(&self.snapshot, output); + if entry.hidden == hidden { continue; } + entry.hidden = hidden; - if let Some(effects) = &entry.effects { - if let Err(error) = effects.set_visible(!hidden) { - eprintln!( - "ferese-shell: could not update panel material: {error}" - ); - } + if let Some(effects) = &entry.effects + && let Err(error) = effects.set_visible(!hidden) + { + eprintln!("ferese-shell: could not update panel material: {error}"); } tasks.push(set_input_zone( entry.bar, @@ -1058,7 +1067,6 @@ impl FereseShell { // accounts for the top margin separately. exclusive_zone: (bar.height.round() as i32) .saturating_add(shell_theme.bar_window_gap), - ..Default::default() }, Some(Box::new(move |app| app.view_layer(bar_surface_id))), ); @@ -1113,12 +1121,11 @@ impl FereseShell { .notes .iter_mut() .find(|note| note.id == id) + && (note.text != value || self.note_error.is_some()) { - if note.text != value || self.note_error.is_some() { - note.text = value.clone(); - self.note_pending - .push(note_store::Edit::Text(id.to_owned(), value)); - } + note.text = value.clone(); + self.note_pending + .push(note_store::Edit::Text(id.to_owned(), value)); } self.flush_note_changes() } @@ -1227,7 +1234,6 @@ impl FereseShell { exclusive_zone: -1, size: Some((Some(dimensions.0), Some(dimensions.1))), size_limits: Limits::NONE, - ..Default::default() }, Some(Box::new(Self::view_note_drag)), ); @@ -1356,7 +1362,6 @@ impl FereseShell { exclusive_zone: -1, size: Some((Some(note.width), Some(note.height))), size_limits: Limits::NONE, - ..Default::default() } }, Some(Box::new(move |app| app.view_note(¬e_id, id))), @@ -1581,7 +1586,6 @@ impl FereseShell { exclusive_zone: -1, size: Some((Some(clock.width), Some(clock.height))), size_limits: Limits::NONE, - ..Default::default() } }, Some(Box::new(move |app| app.view_desktop_clock(id))), @@ -1854,14 +1858,13 @@ impl FereseShell { } } - cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) => { + cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) if self .note_drag .as_ref() - .is_some_and(|drag| drag.source == id) - { - return self.finish_note_drag(true); - } + .is_some_and(|drag| drag.source == id) => + { + return self.finish_note_drag(true); } _ => {} } @@ -1987,20 +1990,20 @@ impl FereseShell { surface, frame_id, ))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => { - if let Some(menu) = &mut self.menu { - if menu.effects.is_none() { - match EffectsBinding::attach_role( - &surface, - menu.kind.material_role(), - menu.progress(), - ) { - Ok(binding) => menu.effects = Some(binding), - Err(error) => { - eprintln!("ferese-shell: popover material unavailable: {error}") - } + if let Some(menu) = &mut self.menu + && menu.effects.is_none() + { + match EffectsBinding::attach_role( + &surface, + menu.kind.material_role(), + menu.progress(), + ) { + Ok(binding) => menu.effects = Some(binding), + Err(error) => { + eprintln!("ferese-shell: popover material unavailable: {error}") } - EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); } + EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); } Task::none() } @@ -2022,6 +2025,7 @@ impl FereseShell { .is_some_and(|entry| entry.id == layer_id) => { let entry = self.notification_surface.as_mut().unwrap(); + if entry.effects.is_none() { match EffectsBinding::attach_role(&surface, None, 1.0) { Ok(binding) => entry.effects = Some(binding), @@ -2049,6 +2053,7 @@ impl FereseShell { let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else { return; }; + if entry.effects.is_some() { return; } @@ -2493,10 +2498,10 @@ fn clamp_note_position( cosmic::iced::Point::new( position .x - .clamp(0., ((output.0 as f32 - size.0 as f32).max(0.)).min(8192.)), + .clamp(0., (output.0 as f32 - size.0 as f32).clamp(0., 8192.)), position .y - .clamp(0., ((output.1 as f32 - size.1 as f32).max(0.)).min(8192.)), + .clamp(0., (output.1 as f32 - size.1 as f32).clamp(0., 8192.)), ) } diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs index 0c8bcc2..76b316f 100644 --- a/shell/ferese-shell/src/notification_ui.rs +++ b/shell/ferese-shell/src/notification_ui.rs @@ -520,7 +520,6 @@ impl FereseShell { color: color_with_opacity(palette.text_muted, 0.18 * opacity), width: if history { 0.0 } else { 1.0 }, radius: palette.material_radius.into(), - ..Default::default() }, ..Default::default() })); diff --git a/shell/ferese-shell/src/notifications.rs b/shell/ferese-shell/src/notifications.rs index 379ccc9..37a21b5 100644 --- a/shell/ferese-shell/src/notifications.rs +++ b/shell/ferese-shell/src/notifications.rs @@ -129,7 +129,9 @@ impl Server { title: limited(summary, 160), body: limited(body, 500), actions: actions - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .take(8) .map(|pair| (limited(&pair[0], 128), limited(&pair[1], 40))) .collect(), diff --git a/shell/ferese-shell/src/recording.rs b/shell/ferese-shell/src/recording.rs index b93293e..9c37777 100644 --- a/shell/ferese-shell/src/recording.rs +++ b/shell/ferese-shell/src/recording.rs @@ -80,18 +80,20 @@ impl Recorder { let (send, receive) = mpsc::sync_channel(8); self.updates = Some(receive); self.state = State::Selecting; + std::thread::spawn(move || { let mut output = BufReader::new(output); + loop { let mut line = String::new(); match output.by_ref().take(65537).read_line(&mut line) { Ok(0) | Err(_) => break, Ok(_) if line.len() > 65536 => break, Ok(_) => { - if let Ok(update) = serde_json::from_str::(&line) { - if send.send(Event::Update(update)).is_err() { - break; - } + if let Ok(update) = serde_json::from_str::(&line) + && send.send(Event::Update(update)).is_err() + { + break; } } } diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index 0babe8f..b5e1014 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -158,13 +158,10 @@ impl Service { let mut network = dbus_cache::Cache::new("org.freedesktop.NetworkManager"); let mut bluetooth = dbus_cache::Cache::new("org.bluez"); - loop { - // A poll overlapping a write is discarded below. Wait for the - // write to finish before retrying instead of launching commands - // repeatedly while a slow control operation is still running. - let Some(before) = wait_for_poll(&polling) else { - break; - }; + // A poll overlapping a write is discarded below. Wait for the + // write to finish before retrying instead of launching commands + // repeatedly while a slow control operation is still running. + while let Some(before) = wait_for_poll(&polling) { let mut snapshot = poll(&mut system_bus, &mut session_bus); snapshot.network = network.read(before, network::read); snapshot.bluetooth = bluetooth.read(before, bluetooth::read); @@ -186,16 +183,14 @@ impl Service { }); thread::spawn(move || { - loop { - let first = match commands.recv() { - Ok(command) => command, - Err(_) => break, - }; + while let Ok(first) = commands.recv() { let mut pending = vec![first]; + for command in commands.try_iter() { pending.retain(|(_, action)| action.key() != command.1.key()); pending.push(command); } + for (id, action) in pending { { let mut state = shared.0.lock().unwrap(); @@ -210,6 +205,7 @@ impl Service { shared.1.notify_one(); } } + shared.0.lock().unwrap().3 = true; shared.1.notify_one(); }); diff --git a/shell/ferese-shell/src/status_ui.rs b/shell/ferese-shell/src/status_ui.rs index 07217f9..781e991 100644 --- a/shell/ferese-shell/src/status_ui.rs +++ b/shell/ferese-shell/src/status_ui.rs @@ -89,24 +89,29 @@ impl OpenMenu { impl FereseShell { pub fn open_menu(&mut self, kind: Menu, anchor: Rectangle) -> Task { if self.menu.as_ref().is_some_and(|menu| menu.kind == kind) { - if let Some(menu) = &mut self.menu { - if menu.motion.closing() { - menu.motion.retarget(1.0, Instant::now()); - return Task::none(); - } + if let Some(menu) = &mut self.menu + && menu.motion.closing() + { + menu.motion.retarget(1.0, Instant::now()); + return Task::none(); } + return self.close_menu(); } + if kind == Menu::Calendar { self.calendar_offset = 0; } + let destroy = self.destroy_menu(); if !kind.available(&self.status) { return destroy; } + if kind == Menu::Notifications && self.notifications.ready { self.notifications.toggle_history(); } + let notifications = self.sync_notification_surface(); let id = window::Id::unique(); self.menu = Some(OpenMenu { @@ -303,6 +308,7 @@ impl FereseShell { )] .spacing(4) .align_y(Alignment::Center); + if kind == Menu::Recording && let Some(elapsed) = self.recorder.elapsed() { @@ -312,14 +318,16 @@ impl FereseShell { .class(theme::Text::Color(foreground)), ); } - if kind == Menu::Battery && self.config.status.battery_percentage { - if let Some(battery) = &self.status.battery { - content = content.push( - text(format!("{}%", battery.percent)) - .size(metrics.text_size) - .class(theme::Text::Color(foreground)), - ); - } + + if kind == Menu::Battery + && self.config.status.battery_percentage + && let Some(battery) = &self.status.battery + { + content = content.push( + text(format!("{}%", battery.percent)) + .size(metrics.text_size) + .class(theme::Text::Color(foreground)), + ); } if kind == Menu::Notifications && self @@ -492,6 +500,7 @@ impl FereseShell { let combined = menu.kind == Menu::System; if combined { let mut connections = row::with_capacity(3).spacing(8).width(Length::Fill); + if let Some(n) = &self.status.network { connections = connections.push(connection_control( "Wi-Fi", @@ -507,6 +516,7 @@ impl FereseShell { p, )); } + if let Some(b) = &self.status.bluetooth { connections = connections.push(connection_control( "Bluetooth", @@ -522,6 +532,7 @@ impl FereseShell { p, )); } + if let Some(b) = &self.status.battery { connections = connections.push(connection_control( "Battery", @@ -533,6 +544,7 @@ impl FereseShell { p, )); } + if self.status.network.is_some() || self.status.bluetooth.is_some() || self.status.battery.is_some() @@ -540,97 +552,102 @@ impl FereseShell { rows = rows.push(control_card(connections.into(), primary, p)); } } - if menu.kind == Menu::Network { - if let Some(n) = &self.status.network { - let connected = n.enabled && n.connection.is_some(); - rows = rows.push(status_summary( - status_icon(Menu::Network, &self.status).0, - if !n.enabled { - "Wi-Fi is off" - } else { - n.connection.as_deref().unwrap_or("Not connected") - }, - if connected { - "Connected" - } else if n.enabled { - "No active wireless connection" - } else { - "Wireless connections are paused" - }, - primary, - muted, - color_with_opacity(theme.accent, p), - p, - Some((n.enabled, Action::Wifi(!n.enabled))), - )); + + if menu.kind == Menu::Network + && let Some(n) = &self.status.network + { + let connected = n.enabled && n.connection.is_some(); + rows = rows.push(status_summary( + status_icon(Menu::Network, &self.status).0, + if !n.enabled { + "Wi-Fi is off" + } else { + n.connection.as_deref().unwrap_or("Not connected") + }, if connected { - let strength = match n.signal { - 75.. => "Excellent", - 50..=74 => "Good", - 25..=49 => "Fair", - _ => "Weak", - }; - rows = rows.push( + "Connected" + } else if n.enabled { + "No active wireless connection" + } else { + "Wireless connections are paused" + }, + primary, + muted, + color_with_opacity(theme.accent, p), + p, + Some((n.enabled, Action::Wifi(!n.enabled))), + )); + if connected { + let strength = match n.signal { + 75.. => "Excellent", + 50..=74 => "Good", + 25..=49 => "Fair", + _ => "Weak", + }; + rows = rows.push( + column![ + row![ + text("Signal strength").size(12).width(Length::Fill), + text(strength).size(12) + ], + level_meter(n.signal, primary, p), + ] + .spacing(8), + ); + } + } + + if menu.kind == Menu::Bluetooth + && let Some(b) = &self.status.bluetooth + { + rows = rows.push(status_summary( + status_icon(Menu::Bluetooth, &self.status).0, + if b.enabled { + "Bluetooth is on" + } else { + "Bluetooth is off" + }, + if !b.enabled { + "Device connections are paused" + } else if b.devices.is_empty() { + "No devices connected" + } else { + "Your connected devices" + }, + primary, + muted, + color_with_opacity(theme.accent, p), + p, + Some((b.enabled, Action::Bluetooth(!b.enabled))), + )); + + if b.enabled && !b.devices.is_empty() { + let mut devices = column::with_capacity(b.devices.len()).spacing(8); + for device in &b.devices { + devices = devices.push( column![ - row![ - text("Signal strength").size(12).width(Length::Fill), - text(strength).size(12) - ], - level_meter(n.signal, primary, p), + text(device).size(14), + text("Connected").size(12).class(theme::Text::Color(muted)), ] - .spacing(8), + .spacing(3), ); } - } - } - if menu.kind == Menu::Bluetooth { - if let Some(b) = &self.status.bluetooth { - rows = rows.push(status_summary( - status_icon(Menu::Bluetooth, &self.status).0, - if b.enabled { - "Bluetooth is on" + + let list: Element<'_, cosmic::Action> = + if let Some(height) = device_list_height(b.devices.len()) { + cosmic::iced::widget::scrollable(devices) + .height(height) + .width(Length::Fill) + .into() } else { - "Bluetooth is off" - }, - if !b.enabled { - "Device connections are paused" - } else if b.devices.is_empty() { - "No devices connected" - } else { - "Your connected devices" - }, - primary, - muted, - color_with_opacity(theme.accent, p), - p, - Some((b.enabled, Action::Bluetooth(!b.enabled))), - )); - if b.enabled && !b.devices.is_empty() { - let mut devices = column::with_capacity(b.devices.len()).spacing(8); - for device in &b.devices { - devices = devices.push( - column![ - text(device).size(14), - text("Connected").size(12).class(theme::Text::Color(muted)), - ] - .spacing(3), - ); - } - let list: Element<'_, cosmic::Action> = - if let Some(height) = device_list_height(b.devices.len()) { - cosmic::iced::widget::scrollable(devices) - .height(height) - .width(Length::Fill) - .into() - } else { - devices.into() - }; - rows = rows.push(list); - } + devices.into() + }; + rows = rows.push(list); } - } - if combined || menu.kind == Menu::Audio { - if let Some(a) = &self.status.audio { + + if (combined || menu.kind == Menu::Audio) + && let Some(a) = &self.status.audio + { let audio = column![ row![ text("Volume").size(13).width(Length::Fill), @@ -661,55 +678,57 @@ impl FereseShell { rows = rows.push(audio); } } - if combined { - if let Some(value) = self.status.brightness { - let brightness = column![ - text("Brightness").size(13), - slider_row( - include_bytes!("../assets/icons/status/brightness.svg"), - value, - true, - primary, - color_with_opacity(theme.accent, p), - p, + + if combined && let Some(value) = self.status.brightness { + let brightness = column![ + text("Brightness").size(13), + slider_row( + include_bytes!("../assets/icons/status/brightness.svg"), + value, + true, + primary, + color_with_opacity(theme.accent, p), + p, + ) + ] + .spacing(6); + rows = rows.push(control_card(brightness.into(), primary, p)); + } + + if (combined || menu.kind == Menu::Notifications) + && let Some(n) = &self.status.notifications + { + let toggle = toggle_row("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), theme, p); + rows = rows.push(if combined { + control_card(toggle, primary, p) + } else { + toggle + }); + + if menu.kind == Menu::Notifications { + rows = rows + .push( + text(if n.count == 0 { + "No notifications".to_owned() + } else if n.count == 1 { + "1 notification".to_owned() + } else { + format!("{} notifications", n.count) + }) + .size(13) + .class(theme::Text::Color(muted)), ) - ] - .spacing(6); - rows = rows.push(control_card(brightness.into(), primary, p)); + .push(menu_button( + "Open notification center", + Message::Control(Action::Notifications), + primary, + p, + )); } - } - if combined || menu.kind == Menu::Notifications { - if let Some(n) = &self.status.notifications { - let toggle = toggle_row("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), theme, p); - rows = rows.push(if combined { - control_card(toggle, primary, p) - } else { - toggle - }); - if menu.kind == Menu::Notifications { - rows = rows - .push( - text(if n.count == 0 { - "No notifications".to_owned() - } else if n.count == 1 { - "1 notification".to_owned() - } else { - format!("{} notifications", n.count) - }) - .size(13) - .class(theme::Text::Color(muted)), - ) - .push(menu_button( - "Open notification center", - Message::Control(Action::Notifications), - primary, - p, - )); - } - } - } - if menu.kind == Menu::Battery { - if let Some(b) = &self.status.battery { + + if menu.kind == Menu::Battery + && let Some(b) = &self.status.battery + { let battery_color = if b.percent < self.config.status.low_battery_threshold && b.status != "Charging" { @@ -752,95 +771,103 @@ impl FereseShell { .class(theme::Text::Color(muted)), ); } - } - if menu.kind == Menu::Battery { - rows = rows.push(text("Power mode").size(13).class(theme::Text::Color(muted))); - if let Some(profiles) = &self.status.power_profiles { - let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill); - for (index, (label, icon, profile)) in [ + + if menu.kind == Menu::Battery { + rows = rows.push(text("Power mode").size(13).class(theme::Text::Color(muted))); + if let Some(profiles) = &self.status.power_profiles { + let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill); + for (index, (label, icon, profile)) in [ + ( + "Power saver", + include_bytes!("../assets/icons/status/power-saver.svg").as_slice(), + "power-saver", + ), + ( + "Balanced", + include_bytes!("../assets/icons/status/power-balanced.svg") + .as_slice(), + "balanced", + ), + ( + "Performance", + include_bytes!("../assets/icons/status/power-performance.svg") + .as_slice(), + "performance", + ), + ] + .into_iter() + .enumerate() + { + if profiles.available[index] { + modes = modes.push(power_tile( + label, + icon, + profile, + profiles.active == profile, + theme, + p, + )); + } + } + // TODO: confirm column align fix + rows = rows.push(modes.width(Length::Fill).align_y(Alignment::Center)); + } else { + rows = rows.push( + text("Power Profiles service unavailable") + .size(12) + .class(theme::Text::Color(muted)), + ); + } + + let mut power = row::with_capacity(3).spacing(6).width(Length::Fill); + for (enabled, label, icon, action) in [ ( - "Battery saver", - include_bytes!("../assets/icons/status/power-saver.svg").as_slice(), - "power-saver", + self.status.reboot, + "Restart", + include_bytes!("../assets/icons/status/restart.svg").as_slice(), + Action::Reboot, ), ( - "Balanced", - include_bytes!("../assets/icons/status/power-balanced.svg").as_slice(), - "balanced", + self.status.poweroff, + "Power off", + include_bytes!("../assets/icons/status/power-off.svg").as_slice(), + Action::Poweroff, ), ( - "Performance", - include_bytes!("../assets/icons/status/power-performance.svg") - .as_slice(), - "performance", + self.status.suspend, + "Suspend", + include_bytes!("../assets/icons/status/suspend.svg").as_slice(), + Action::Suspend, ), - ] - .into_iter() - .enumerate() - { - if profiles.available[index] { - modes = modes.push(power_tile( - label, - icon, - profile, - profiles.active == profile, - theme, - p, - )); + ] { + if enabled { + power = power.push(power_tile_action(label, icon, action, theme, p)); } } - rows = rows.push(modes); - } else { + + if self.status.reboot || self.status.poweroff || self.status.suspend { + rows = rows.push(text("Power").size(13).class(theme::Text::Color(muted))); + rows = rows.push(power.align_y(Alignment::Center).width(Length::Fill)); + } + } + + if !menu.kind.available(&self.status) { rows = rows.push( - text("Power Profiles service unavailable") - .size(12) + text("Service unavailable") + .size(13) .class(theme::Text::Color(muted)), ); } - let mut power = row::with_capacity(3).spacing(6).width(Length::Fill); - for (enabled, label, icon, action) in [ - ( - self.status.reboot, - "Restart", - include_bytes!("../assets/icons/status/restart.svg").as_slice(), - Action::Reboot, - ), - ( - self.status.poweroff, - "Power off", - include_bytes!("../assets/icons/status/power-off.svg").as_slice(), - Action::Poweroff, - ), - ( - self.status.suspend, - "Suspend", - include_bytes!("../assets/icons/status/suspend.svg").as_slice(), - Action::Suspend, - ), - ] { - if enabled { - power = power.push(power_tile_action(label, icon, action, theme, p)); - } - } - if self.status.reboot || self.status.poweroff || self.status.suspend { - rows = rows.push(text("Power").size(13).class(theme::Text::Color(muted))); - rows = rows.push(power); - } - } - if !menu.kind.available(&self.status) { - rows = rows.push( - text("Service unavailable") - .size(13) - .class(theme::Text::Color(muted)), - ); } } + if let Some(error) = &self.status_error { rows = rows.push(text(error).size(12).class(theme::Text::Color(Color { a: p, ..Color::from_rgb8(230, 172, 90) }))); } + let compositor_material = menu.effects.is_some(); let panel = container(rows) .id("ferese-blur-card") @@ -856,10 +883,10 @@ impl FereseShell { color: color_with_opacity(theme.text_muted, 0.18 * p), width: 1.0, radius: theme.material_radius.into(), - ..Default::default() }, ..Default::default() })); + cosmic::widget::autosize::autosize( super::motion::animated( panel.into(), @@ -1111,20 +1138,20 @@ fn power_tile<'a>( }, opacity, ); - let tile = column![bar_icon(icon, 19, foreground), text(label).size(11)] - .spacing(3) - .align_x(Alignment::Center); - motion::button( - button::custom(tile) - .width(Length::FillPortion(1)) - .padding([8, 2]) - .on_press(cosmic::Action::App(Message::Control(Action::PowerProfile( - profile, - )))), - foreground, - selected, - opacity, - ) + let tile = column![ + bar_icon(icon, 19, foreground), + text(label).size(11).align_x(Alignment::Center) + ] + .spacing(3) + .align_x(Alignment::Center); + let btn = button::custom(tile.align_x(Alignment::Center)) + .width(Length::FillPortion(1)) + .padding([8, 2]) + .on_press(cosmic::Action::App(Message::Control(Action::PowerProfile( + profile, + )))); + + motion::button(btn, foreground, selected, opacity) } fn power_tile_action<'a>( @@ -1135,6 +1162,7 @@ fn power_tile_action<'a>( opacity: f32, ) -> Element<'a, cosmic::Action> { let foreground = color_with_opacity(theme.text_primary, opacity); + // TODO: confirm column align fix motion::button( button::custom( column![bar_icon(icon, 19, foreground), text(label).size(11)] @@ -1623,12 +1651,14 @@ mod tests { #[test] fn icons_follow_real_states() { - let mut s = Snapshot::default(); - s.audio = Some(status::Audio { - volume: 80, - muted: true, - output: String::new(), - }); + let mut s = Snapshot { + audio: Some(status::Audio { + volume: 80, + muted: true, + output: String::new(), + }), + ..Default::default() + }; assert_eq!( status_icon(Menu::Audio, &s), ( @@ -1636,6 +1666,7 @@ mod tests { false ) ); + s.battery = Some(status::Battery { percent: 2, status: "Charging".into(), @@ -1644,6 +1675,7 @@ mod tests { status_icon(Menu::Battery, &s).0, include_bytes!("../assets/icons/status/battery-charging.svg") ); + s.notifications = Some(status::Notifications { count: 3, dnd: true, @@ -1656,6 +1688,7 @@ mod tests { status_label(Menu::Notifications, &s), "Notifications: do not disturb" ); + s.bluetooth = Some(status::Bluetooth { enabled: false, devices: vec!["Headphones".into()],