From 4e130cae73c28acb18ba69586d7770aa6d8020e3 Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Wed, 19 Nov 2025 01:32:33 +0100 Subject: [PATCH 1/2] implement custom renderer for display arrangement --- .../src/pages/display/arrangement.rs | 1172 ++++++++++------- cosmic-settings/src/pages/display/mod.rs | 448 +++++-- i18n/en/cosmic_settings.ftl | 2 + 3 files changed, 1001 insertions(+), 621 deletions(-) diff --git a/cosmic-settings/src/pages/display/arrangement.rs b/cosmic-settings/src/pages/display/arrangement.rs index 71a29ed6..3d13ad3e 100644 --- a/cosmic-settings/src/pages/display/arrangement.rs +++ b/cosmic-settings/src/pages/display/arrangement.rs @@ -1,630 +1,788 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -use cosmic::Renderer; -use cosmic::iced_core::renderer::Quad; -use cosmic::iced_core::widget::{Tree, tree}; -use cosmic::iced_core::{ - self as core, Border, Clipboard, Element, Layout, Length, Rectangle, Renderer as IcedRenderer, - Shell, Size, Widget, +use std::cell::RefCell; + +use cosmic::iced::widget::canvas::{ + Canvas, Frame, Geometry, Path, Program, Stroke, Text, + event::{self as canvas_event, Status}, }; -use cosmic::iced_core::{Point, layout, mouse, renderer, touch}; -use cosmic::iced_core::{alignment, event, text}; -use cosmic::widget::segmented_button::{self, SingleSelectModel}; -use cosmic_randr_shell::{self as randr, OutputKey}; -use randr::Transform; +use cosmic::iced::{Color, Point, Rectangle, Size, Vector, mouse}; +use cosmic::iced_widget::core::Length; +use cosmic::widget::segmented_button::SingleSelectModel; +use cosmic::{Element, Renderer, Theme}; +use cosmic_randr_shell as randr; + +use super::{OutputKey, Page}; + +const CAMERA_FIT_PADDING: f32 = 1.2; +const HORIZONTAL_BIAS: f32 = 1.25; +const MIN_OVERLAP_PIXELS: f32 = 50.0; +const DISPLAY_CORNER_RADIUS: f32 = 4.0; +const DISPLAY_BORDER_WIDTH: f32 = 3.0; +const BADGE_WIDTH: f32 = 72.0; +const BADGE_HEIGHT: f32 = 46.0; +const BADGE_CORNER_RADIUS: f32 = 30.0; +const BADGE_FONT_SIZE: f32 = 24.0; +const SCALE_THRESHOLD: f32 = 0.05; +const CENTER_THRESHOLD: f32 = 50.0; +pub(super) const EDGE_TOLERANCE: f32 = 1.0; + + +#[derive(Debug, Clone)] +pub struct Camera2D { + pub position: Point, + pub scale: f32, +} + +impl Default for Camera2D { + fn default() -> Self { + Self { + position: Point::ORIGIN, + scale: 1.0, + } + } +} -const UNIT_PIXELS: f32 = 12.0; -const VERTICAL_OVERHEAD: f32 = 1.5; -const VERTICAL_DISPLAY_OVERHEAD: f32 = 4.0; +impl Camera2D { + fn screen_to_world(&self, screen_pos: Point, viewport_size: Size) -> Point { + Point { + x: (screen_pos.x - viewport_size.width / 2.0) / self.scale + self.position.x, + y: (screen_pos.y - viewport_size.height / 2.0) / self.scale + self.position.y, + } + } -pub type OnPlacementFunc = Box Message>; -pub type OnSelectFunc = Box Message>; + fn world_to_screen(&self, world_pos: Point, viewport_size: Size) -> Point { + Point { + x: (world_pos.x - self.position.x) * self.scale + viewport_size.width / 2.0, + y: (world_pos.y - self.position.y) * self.scale + viewport_size.height / 2.0, + } + } -#[derive(Copy, Clone, Debug, PartialEq)] -pub enum Pan { - Left, - Right, + fn pan(&mut self, screen_delta: Vector) { + self.position.x -= screen_delta.x / self.scale; + self.position.y -= screen_delta.y / self.scale; + } } -#[must_use] -#[derive(derive_setters::Setters)] -pub struct Arrangement<'a, Message> { - #[setters(skip)] - list: &'a randr::List, - #[setters(skip)] - tab_model: &'a SingleSelectModel, - #[setters(skip)] - on_pan: Option Message>>, - #[setters(skip)] - on_placement: Option>, - #[setters(skip)] - on_select: Option>, - width: Length, - height: Length, +#[derive(Debug, Clone)] +pub struct DisplayRect { + pub output_key: OutputKey, + pub position: Point, + pub size: Size, + pub name: String, } -impl<'a, Message> Arrangement<'a, Message> { - pub fn new(list: &'a randr::List, tab_model: &'a SingleSelectModel) -> Self { - Self { - list, - tab_model, - on_pan: None, - on_placement: None, - on_select: None, - width: Length::Shrink, - height: Length::Shrink, +impl DisplayRect { + fn bounds(&self) -> Rectangle { + Rectangle { + x: self.position.x, + y: self.position.y, + width: self.size.width, + height: self.size.height, } } - pub fn on_pan(mut self, on_pan: impl Fn(Pan) -> Message + 'static) -> Self { - self.on_pan = Some(Box::new(on_pan)); - self + fn contains(&self, point: Point) -> bool { + self.bounds().contains(point) } - pub fn on_placement( - mut self, - on_placement: impl Fn(OutputKey, i32, i32) -> Message + 'static, - ) -> Self { - self.on_placement = Some(Box::new(on_placement)); - self + fn edge_center(&self, edge: Edge) -> Point { + let bounds = self.bounds(); + match edge { + Edge::Left => Point::new(bounds.x, bounds.y + bounds.height / 2.0), + Edge::Right => Point::new(bounds.x + bounds.width, bounds.y + bounds.height / 2.0), + Edge::Top => Point::new(bounds.x + bounds.width / 2.0, bounds.y), + Edge::Bottom => Point::new(bounds.x + bounds.width / 2.0, bounds.y + bounds.height), + } } +} - pub fn on_select( - mut self, - on_select: impl Fn(segmented_button::Entity) -> Message + 'static, - ) -> Self { - self.on_select = Some(Box::new(on_select)); - self - } +#[derive(Debug, Clone, Copy)] +enum Edge { + Left, + Right, + Top, + Bottom, } -impl Widget for Arrangement<'_, Message> { - fn tag(&self) -> tree::Tag { - tree::Tag::of::() - } +impl Edge { + const ALL: [Edge; 4] = [Edge::Left, Edge::Right, Edge::Top, Edge::Bottom]; - fn state(&self) -> tree::State { - tree::State::new(State::default()) + fn distance_bias(&self) -> f32 { + match self { + Edge::Left | Edge::Right => HORIZONTAL_BIAS, + Edge::Top | Edge::Bottom => 1.0, + } } - fn size(&self) -> Size { - Size { - width: self.width, - height: self.height, + fn snap_to(&self, target_bounds: Rectangle, dragged_pos: Point, dragged_size: Size) -> Point { + match self { + Edge::Left => Point::new(target_bounds.x - dragged_size.width, dragged_pos.y), + Edge::Right => Point::new(target_bounds.x + target_bounds.width, dragged_pos.y), + Edge::Top => Point::new(dragged_pos.x, target_bounds.y - dragged_size.height), + Edge::Bottom => Point::new(dragged_pos.x, target_bounds.y + target_bounds.height), } } +} - fn layout( - &self, - tree: &mut Tree, - _renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - // Determine the max display dimensions, and the total display area utilized. - let mut total_height = 0; - let mut max_dimensions = (0, 0); - let mut display_area = (0, 0); - - for output in self.list.outputs.values() { - if !output.enabled { - continue; - } +#[derive(Debug, Clone)] +pub enum Interaction { + None, + Dragging { + output_key: OutputKey, + offset: Vector, + }, + Panning { + last_pos: Point, + }, +} - let Some(mode_key) = output.current else { - continue; - }; +#[inline] +fn distance(a: Point, b: Point) -> f32 { + ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt() +} - let Some(mode) = self.list.modes.get(mode_key) else { - continue; - }; +#[derive(Debug)] +pub struct ArrangementState { + pub camera: RefCell, + pub interaction: Interaction, + pub displays: RefCell>, + pub needs_fit: RefCell, +} - let (mut width, mut height) = if output.transform.is_none_or(is_landscape) { - (mode.size.0, mode.size.1) - } else { - (mode.size.1, mode.size.0) - }; +impl Default for ArrangementState { + fn default() -> Self { + Self { + camera: RefCell::new(Camera2D::default()), + interaction: Interaction::None, + displays: RefCell::new(Vec::new()), + needs_fit: RefCell::new(true), + } + } +} - // Scale dimensions of the display with the output scale. - width = (width as f64 / output.scale) as u32; - height = (height as f64 / output.scale) as u32; +impl ArrangementState { + fn find_display_at(&self, world_pos: Point) -> Option { + self.displays + .borrow() + .iter() + .position(|d| d.contains(world_pos)) + } - max_dimensions.0 = max_dimensions.0.max(width); - max_dimensions.1 = max_dimensions.1.max(height); + fn needs_sync(&self, list: &randr::List, tab_model: &SingleSelectModel) -> bool { + let enabled_count = tab_model + .iter() + .filter_map(|id| tab_model.data::(id)) + .filter(|&&key| { + list.outputs + .get(key) + .map(|output| output.enabled) + .unwrap_or(false) + }) + .count(); + + let displays = self.displays.borrow(); + + if displays.len() != enabled_count { + return true; + } - display_area.0 = display_area.0.max(width as i32 + output.position.0); - display_area.1 = display_area.1.max(height as i32 + output.position.1); + displays.iter().any(|disp| { + list.outputs + .get(disp.output_key) + .map(|output| { + let randr_pos = Point::new( + output.position.0 as f32, + output.position.1 as f32, + ); + randr_pos != disp.position + }) + .unwrap_or(false) + }) + } - total_height = total_height.max(height as i32 + output.position.1); + fn calculate_bounding_box(&self) -> Option { + let displays = self.displays.borrow(); + if displays.is_empty() { + return None; } - let state = tree.state.downcast_mut::(); + let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); + let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); - state.max_dimensions = ( - max_dimensions.0 as f32 / UNIT_PIXELS, - total_height as f32 / UNIT_PIXELS, - ); + for disp in displays.iter() { + min_x = min_x.min(disp.position.x); + min_y = min_y.min(disp.position.y); + max_x = max_x.max(disp.position.x + disp.size.width); + max_y = max_y.max(disp.position.y + disp.size.height); + } - let width = ((max_dimensions.0 as f32 * 2.0) as i32 + display_area.0) as f32 / UNIT_PIXELS; - let height = total_height as f32 * VERTICAL_OVERHEAD / UNIT_PIXELS; + Some(Rectangle { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + }) + } - let limits = limits - .width(Length::Fixed(width)) - .height(Length::Fixed(height)); + fn calculate_edge_overlap( + edge: Edge, + rect_pos: Point, + rect_size: Size, + target: Rectangle, + ) -> f32 { + let (overlap_start, overlap_end) = match edge { + Edge::Left | Edge::Right => ( + rect_pos.y.max(target.y), + (rect_pos.y + rect_size.height).min(target.y + target.height), + ), + Edge::Top | Edge::Bottom => ( + rect_pos.x.max(target.x), + (rect_pos.x + rect_size.width).min(target.x + target.width), + ), + }; + (overlap_end - overlap_start).max(0.0) + } + + fn rectangles_overlap(a_pos: Point, a_size: Size, b: Rectangle) -> bool { + a_pos.x < b.x + b.width + && a_pos.x + a_size.width > b.x + && a_pos.y < b.y + b.height + && a_pos.y + a_size.height > b.y + } - let size = limits.resolve(width, height, Size::ZERO); + fn is_connected_to_any(&self, dragged_key: OutputKey, position: Point, size: Size) -> bool { + self.displays.borrow().iter().any(|other| { + if other.output_key == dragged_key { + return false; + } - layout::Node::new(size) + Self::are_rectangles_adjacent(position, size, &other.bounds()) + }) } - fn on_event( - &mut self, - tree: &mut Tree, - event: cosmic::iced_core::Event, - layout: Layout<'_>, - cursor: mouse::Cursor, - _renderer: &Renderer, - _clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, Message>, - viewport: &Rectangle, - ) -> event::Status { - let bounds = layout.bounds(); + fn are_rectangles_adjacent(pos: Point, size: Size, other: &Rectangle) -> bool { + let right = pos.x + size.width; + let bottom = pos.y + size.height; + let other_right = other.x + other.width; + let other_bottom = other.y + other.height; - match event { - core::Event::Mouse(mouse::Event::CursorMoved { position, .. }) - | core::Event::Touch(touch::Event::FingerMoved { position, .. }) => { - let state = tree.state.downcast_mut::(); - - if let Some((output_key, region)) = state.dragging.as_mut() { - if let Some(ref mut on_pan) = self.on_pan { - if bounds.x + viewport.width - 150.0 < position.x { - shell.publish(on_pan(Pan::Right)); - } else if bounds.x + 150.0 > position.x { - shell.publish(on_pan(Pan::Left)); - } - } + // Vertical adjacency (left-right touching) + let left_touches_right = (right - other.x).abs() <= EDGE_TOLERANCE; + let right_touches_left = (other_right - pos.x).abs() <= EDGE_TOLERANCE; + let has_vertical_overlap = bottom > other.y && pos.y < other_bottom; - if let Some(inner_position) = cursor.position() { - update_dragged_region( - self.tab_model, - self.list, - &bounds, - *output_key, - region, - state.max_dimensions, - ( - inner_position.x - state.offset.0, - inner_position.y - state.offset.1, - ), - ); + if (left_touches_right || right_touches_left) && has_vertical_overlap { + return true; + } - return event::Status::Captured; - } - } - } + // Horizontal adjacency (top-bottom touching) + let bottom_touches_top = (bottom - other.y).abs() <= EDGE_TOLERANCE; + let top_touches_bottom = (other_bottom - pos.y).abs() <= EDGE_TOLERANCE; + let has_horizontal_overlap = right > other.x && pos.x < other_right; - core::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | core::Event::Touch(touch::Event::FingerPressed { .. }) => { - if let Some(position) = cursor.position() { - let state = tree.state.downcast_mut::(); - if let Some((output_key, output_region)) = display_region_hovers( - self.tab_model, - self.list, - &bounds, - state.max_dimensions, - position, - ) { - state.drag_from = position; - state.offset = (position.x - output_region.x, position.y - output_region.y); - state.dragging = Some((output_key, output_region)); - return event::Status::Captured; - } - } - } + (bottom_touches_top || top_touches_bottom) && has_horizontal_overlap + } - core::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) - | core::Event::Touch(touch::Event::FingerLifted { .. }) => { - let state = tree.state.downcast_mut::(); - if let Some((output_key, region)) = state.dragging.take() { - if let Some(position) = cursor.position() - && position.distance(state.drag_from) < 4.0 - { - if let Some(ref on_select) = self.on_select { - for id in self.tab_model.iter() { - if let Some(&key) = self.tab_model.data::(id) - && key == output_key - { - shell.publish(on_select(id)); - } - } - } + fn would_overlap_any(&self, dragged_key: OutputKey, position: Point, size: Size) -> bool { + self.displays.borrow().iter().any(|other| { + other.output_key != dragged_key && Self::rectangles_overlap(position, size, other.bounds()) + }) + } - return event::Status::Captured; - } + fn fit_camera(&self, viewport_size: Size) { + let Some(bbox) = self.calculate_bounding_box() else { + return; + }; - if let Some(ref on_placement) = self.on_placement { - shell.publish(on_placement( - output_key, - ((region.x - state.max_dimensions.0 - bounds.x) * UNIT_PIXELS) as i32, - ((region.y - - (state.max_dimensions.1 / VERTICAL_DISPLAY_OVERHEAD) - - bounds.y) - * UNIT_PIXELS) as i32, - )); - } + let center = Point::new(bbox.x + bbox.width / 2.0, bbox.y + bbox.height / 2.0); - return event::Status::Captured; - } - } + let scale_x = viewport_size.width / (bbox.width * CAMERA_FIT_PADDING); + let scale_y = viewport_size.height / (bbox.height * CAMERA_FIT_PADDING); + let scale = scale_x.min(scale_y).min(1.0); - _ => (), - } + let mut camera = self.camera.borrow_mut(); + camera.position = center; + camera.scale = scale; - event::Status::Ignored + *self.needs_fit.borrow_mut() = false; } - fn mouse_interaction( + fn find_best_snap_target( &self, - tree: &Tree, - layout: Layout<'_>, - cursor: mouse::Cursor, - _viewport: &Rectangle, - _renderer: &Renderer, - ) -> mouse::Interaction { - let state = tree.state.downcast_ref::(); - let bounds = layout.bounds(); - - for (_output_key, region) in - display_regions(self.tab_model, self.list, &bounds, state.max_dimensions) - { - if cursor.is_over(region) { - return mouse::Interaction::Grab; + dragged_key: OutputKey, + position: Point, + size: Size, + ) -> Option<(Edge, Rectangle, OutputKey)> { + let center = Point::new( + position.x + size.width / 2.0, + position.y + size.height / 2.0, + ); + + let mut best_distance = f32::MAX; + let mut best_edge = None; + let mut best_target = Rectangle::default(); + let mut best_target_key = None; + + for other in self.displays.borrow().iter() { + if other.output_key == dragged_key { + continue; + } + + let other_bounds = other.bounds(); + + for edge in Edge::ALL { + let edge_center = other.edge_center(edge); + let dist = distance(edge_center, center) * edge.distance_bias(); + let test_snap_pos = edge.snap_to(other_bounds, position, size); + let overlap = Self::calculate_edge_overlap(edge, test_snap_pos, size, other_bounds); + + if overlap >= MIN_OVERLAP_PIXELS && dist < best_distance { + best_distance = dist; + best_edge = Some(edge); + best_target = other_bounds; + best_target_key = Some(other.output_key); + } } } - mouse::Interaction::Idle + best_edge.map(|edge| (edge, best_target, best_target_key.unwrap())) } - fn draw( + fn apply_snapping( &self, - tree: &Tree, - renderer: &mut Renderer, - _theme: &cosmic::Theme, - _style: &renderer::Style, - layout: Layout<'_>, - _cursor: mouse::Cursor, - viewport: &Rectangle, - ) { - let state = tree.state.downcast_ref::(); + dragged_key: OutputKey, + position: Point, + size: Size, + current_position: Point, + ) -> Point { + // No snapping if this is the only display + let has_other_displays = self + .displays + .borrow() + .iter() + .any(|d| d.output_key != dragged_key); + + if !has_other_displays { + return position; + } - let bounds = layout.bounds(); - let theme = cosmic::theme::active(); - let cosmic_theme = theme.cosmic(); + let Some((edge, target, _target_key)) = + self.find_best_snap_target(dragged_key, position, size) + else { + return current_position; + }; - let border_color = cosmic_theme.palette.neutral_7; + let snapped_pos = edge.snap_to(target, position, size); - let active_key = self.tab_model.active_data::(); + // Validate the snapped position + let overlap = Self::calculate_edge_overlap(edge, snapped_pos, size, target); + let is_valid = overlap >= MIN_OVERLAP_PIXELS + && self.is_connected_to_any(dragged_key, snapped_pos, size) + && !self.would_overlap_any(dragged_key, snapped_pos, size); - for (id, (output_key, mut region)) in - display_regions(self.tab_model, self.list, &bounds, state.max_dimensions).enumerate() - { - // If the output is being dragged, show its dragged position instead. - if let Some((dragged_key, dragged_region)) = state.dragging - && dragged_key == output_key - { - region = dragged_region; - } + if is_valid { + snapped_pos + } else { + current_position + } + } - let (background, border_color) = if Some(&output_key) == active_key { - let mut border_color = border_color; - border_color.alpha = 0.4; + fn sync_displays(&self, list: &randr::List, tab_model: &SingleSelectModel) { + let mut displays = self.displays.borrow_mut(); + let old_count = displays.len(); + let old_positions: std::collections::HashMap<_, _> = displays + .iter() + .map(|d| (d.output_key, d.position)) + .collect(); - (cosmic_theme.accent_color(), border_color) - } else { - (cosmic_theme.palette.neutral_4, border_color) + displays.clear(); + + for id in tab_model.iter() { + let Some(&key) = tab_model.data::(id) else { + continue; }; - renderer.fill_quad( - Quad { - bounds: region, - border: Border { - color: border_color.into(), - radius: 4.0.into(), - width: 3.0, - }, - shadow: Default::default(), - }, - core::Background::Color(background.into()), - ); + let Some(output) = list.outputs.get(key) else { + continue; + }; - let id_bounds = Rectangle { - x: region.x + (region.width / 2.0 - 36.0), - y: region.y + (region.height / 2.0 - 23.0), - width: 72.0, - height: 46.0, + if !output.enabled { + continue; + } + + let Some(mode_key) = output.current else { + continue; }; - renderer.fill_quad( - Quad { - bounds: id_bounds, - border: Border { - radius: 30.0.into(), - ..Default::default() - }, - shadow: Default::default(), - }, - core::Background::Color(cosmic_theme.palette.neutral_1.into()), - ); + let Some(mode) = list.modes.get(mode_key) else { + continue; + }; - core::text::Renderer::fill_text( - renderer, - core::Text { - content: itoa::Buffer::new().format(id + 1).to_string(), - size: core::Pixels(24.0), - line_height: core::text::LineHeight::Relative(1.2), - font: cosmic::font::bold(), - bounds: id_bounds.size(), - horizontal_alignment: alignment::Horizontal::Center, - vertical_alignment: alignment::Vertical::Center, - shaping: text::Shaping::Basic, - wrapping: text::Wrapping::Word, - }, - core::Point { - x: id_bounds.center_x(), - y: id_bounds.center_y(), - }, - cosmic_theme.palette.neutral_10.into(), - *viewport, - ); + let (width, height) = if output.transform.is_none_or(Page::is_landscape) { + ( + mode.size.0 as f32 / output.scale as f32, + mode.size.1 as f32 / output.scale as f32, + ) + } else { + ( + mode.size.1 as f32 / output.scale as f32, + mode.size.0 as f32 / output.scale as f32, + ) + }; + + displays.push(DisplayRect { + output_key: key, + position: Point::new(output.position.0 as f32, output.position.1 as f32), + size: Size::new(width, height), + name: output.name.clone(), + }); } - } -} -impl<'a, Message: 'static + Clone> From> for cosmic::Element<'a, Message> { - fn from(display_positioner: Arrangement<'a, Message>) -> Self { - Element::new(display_positioner) - } -} + let positions_changed = displays.iter().any(|d| { + old_positions + .get(&d.output_key) + .map(|&old_pos| old_pos != d.position) + .unwrap_or(true) + }); -#[derive(Default)] -struct State { - drag_from: Point, - dragging: Option<(OutputKey, Rectangle)>, - offset: (f32, f32), - max_dimensions: (f32, f32), -} + if displays.len() != old_count || positions_changed { + *self.needs_fit.borrow_mut() = true; + } + } -/// Iteratively calculate display regions for each display output in the list. -fn display_regions<'a>( - model: &'a SingleSelectModel, - list: &'a randr::List, - bounds: &'a Rectangle, - max_dimensions: (f32, f32), -) -> impl Iterator + 'a { - model - .iter() - .filter_map(move |id| model.data::(id)) - .filter_map(move |&key| { - let output = list.outputs.get(key)?; + fn handle_drag_move( + &self, + output_key: OutputKey, + offset: Vector, + world_pos: Point, + bounds_size: Size, + ) { + let raw_pos = Point::new(world_pos.x - offset.x, world_pos.y - offset.y); + + let (display_size, current_pos) = self + .displays + .borrow() + .iter() + .find(|d| d.output_key == output_key) + .map(|d| (d.size, d.position)) + .unwrap_or_default(); + + let snapped_pos = self.apply_snapping(output_key, raw_pos, display_size, current_pos); + + if let Some(disp) = self + .displays + .borrow_mut() + .iter_mut() + .find(|d| d.output_key == output_key) + { + disp.position = snapped_pos; + } - if !output.enabled { - return None; - } + self.check_refit_needed(bounds_size); + } - let mode_key = output.current?; + fn check_refit_needed(&self, bounds_size: Size) { + let Some(bbox) = self.calculate_bounding_box() else { + return; + }; - let mode = list.modes.get(mode_key)?; + let camera = self.camera.borrow(); - let (mut width, mut height) = ( - (mode.size.0 as f32 / output.scale as f32) / UNIT_PIXELS, - (mode.size.1 as f32 / output.scale as f32) / UNIT_PIXELS, - ); + let required_scale_x = bounds_size.width / (bbox.width * CAMERA_FIT_PADDING); + let required_scale_y = bounds_size.height / (bbox.height * CAMERA_FIT_PADDING); + let required_scale = required_scale_x.min(required_scale_y).min(1.0); - (width, height) = if output.transform.is_none_or(is_landscape) { - (width, height) - } else { - (height, width) - }; + let required_center_x = bbox.x + bbox.width / 2.0; + let required_center_y = bbox.y + bbox.height / 2.0; - Some(( - key, - Rectangle { - width, - height, - x: max_dimensions.0 + bounds.x + (output.position.0 as f32) / UNIT_PIXELS, - y: (max_dimensions.1 / VERTICAL_DISPLAY_OVERHEAD) - + bounds.y - + (output.position.1 as f32) / UNIT_PIXELS, - }, - )) - }) -} + let scale_diff = (camera.scale - required_scale).abs() / camera.scale; + let center_diff = ((camera.position.x - required_center_x).powi(2) + + (camera.position.y - required_center_y).powi(2)) + .sqrt(); -fn display_region_hovers( - model: &SingleSelectModel, - list: &randr::List, - bounds: &Rectangle, - max_dimensions: (f32, f32), - point: Point, -) -> Option<(OutputKey, Rectangle)> { - for (output_key, region) in display_regions(model, list, bounds, max_dimensions) { - if region.contains(point) { - return Some((output_key, region)); + if scale_diff > SCALE_THRESHOLD || center_diff > CENTER_THRESHOLD { + *self.needs_fit.borrow_mut() = true; } } +} - None +pub struct ArrangementCanvas<'a, Message> { + list: &'a randr::List, + tab_model: &'a SingleSelectModel, + on_placement: Option Message + 'a>>, + on_select: Option Message + 'a>>, } -/// Updates a display's region, preventing coordinates from overlapping with existing displays. -fn update_dragged_region( - model: &SingleSelectModel, - list: &randr::List, - bounds: &Rectangle, - output: OutputKey, - region: &mut Rectangle, - max_dimensions: (f32, f32), - (x, y): (f32, f32), -) { - let mut dragged_region = Rectangle { x, y, ..*region }; - - let mut nearest = f32::MAX; - let mut nearest_region = Rectangle::default(); - let mut nearest_side = NearestSide::East; - - // Find the nearest adjacent display to the dragged display. - for (other_output, other_region) in display_regions(model, list, bounds, max_dimensions) { - if other_output == output { - continue; +impl<'a, Message: Clone + 'static> ArrangementCanvas<'a, Message> { + pub fn new(list: &'a randr::List, tab_model: &'a SingleSelectModel) -> Self { + Self { + list, + tab_model, + on_placement: None, + on_select: None, } + } - let center = dragged_region.center(); + pub fn on_placement(mut self, f: impl Fn(OutputKey, i32, i32) -> Message + 'a) -> Self { + self.on_placement = Some(Box::new(f)); + self + } - let eastward = distance(east_point(&other_region), center) * 1.25; - let westward = distance(west_point(&other_region), center) * 1.25; - let northward = distance(north_point(&other_region), center); - let southward = distance(south_point(&other_region), center); + pub fn on_select(mut self, f: impl Fn(OutputKey) -> Message + 'a) -> Self { + self.on_select = Some(Box::new(f)); + self + } - let mut nearer = false; + pub fn view(self) -> Element<'a, Message> + where + Message: 'a, + { + Canvas::new(self) + .width(Length::Fill) + .height(Length::Fixed(400.0)) + .into() + } +} - if nearest > eastward { - (nearest, nearest_side, nearer) = (eastward, NearestSide::East, true); - } +impl<'a, Message: Clone> Program for ArrangementCanvas<'a, Message> { + type State = ArrangementState; - if nearest > westward { - (nearest, nearest_side, nearer) = (westward, NearestSide::West, true); + fn draw( + &self, + state: &Self::State, + renderer: &Renderer, + theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + if !matches!(state.interaction, Interaction::Dragging { .. }) + && state.needs_sync(self.list, self.tab_model) + { + state.sync_displays(self.list, self.tab_model); } - if nearest > northward { - (nearest, nearest_side, nearer) = (northward, NearestSide::North, true); - } + let selected_output = self + .tab_model + .data::(self.tab_model.active()) + .copied(); - if nearest > southward { - (nearest, nearest_side, nearer) = (southward, NearestSide::South, true); + if !matches!(state.interaction, Interaction::Dragging { .. }) + && !state.displays.borrow().is_empty() + { + state.check_refit_needed(bounds.size()); } - if nearer { - nearest_region = other_region; + if *state.needs_fit.borrow() && !state.displays.borrow().is_empty() { + state.fit_camera(bounds.size()); } - } - // Attach dragged display to nearest adjacent display. - match nearest_side { - NearestSide::East => { - dragged_region.x = nearest_region.x - dragged_region.width; - dragged_region.y = dragged_region - .y - .max(nearest_region.y - dragged_region.height + 8.0) - .min(nearest_region.y + nearest_region.height - 8.0); - } + let mut frame = Frame::new(renderer, bounds.size()); + let cosmic_theme = theme.cosmic(); - NearestSide::North => { - dragged_region.y = nearest_region.y - dragged_region.height; - dragged_region.x = dragged_region - .x - .max(nearest_region.x - dragged_region.width + 8.0) - .min(nearest_region.x + nearest_region.width - 8.0); - } + frame.fill( + &Path::rectangle(Point::ORIGIN, bounds.size()), + Color::from(cosmic_theme.background.component.base), + ); - NearestSide::West => { - dragged_region.x = nearest_region.x + nearest_region.width; - dragged_region.y = dragged_region - .y - .max(nearest_region.y - dragged_region.height + 8.0) - .min(nearest_region.y + nearest_region.height - 8.0); - } + let camera = state.camera.borrow(); + let border_color = cosmic_theme.palette.neutral_7; - NearestSide::South => { - dragged_region.y = nearest_region.y + nearest_region.height; - dragged_region.x = dragged_region - .x - .max(nearest_region.x - dragged_region.width + 8.0) - .min(nearest_region.x + nearest_region.width - 8.0); - } - } + for (index, disp) in state.displays.borrow().iter().enumerate() { + let world_rect = disp.bounds(); + let screen_pos = camera.world_to_screen(world_rect.position(), bounds.size()); + let screen_size = Size::new( + world_rect.width * camera.scale, + world_rect.height * camera.scale, + ); - // Snap-align on x-axis when alignment is near. - if (dragged_region.x - nearest_region.x).abs() <= 8.0 { - dragged_region.x = nearest_region.x; - } + if screen_pos.x + screen_size.width < 0.0 + || screen_pos.y + screen_size.height < 0.0 + || screen_pos.x > bounds.width + || screen_pos.y > bounds.height + { + continue; + } - // Snap-align on x-axis when alignment is near bottom edge. - if ((dragged_region.x + dragged_region.width) - (nearest_region.x + nearest_region.width)).abs() - <= 8.0 - { - dragged_region.x = nearest_region.x + nearest_region.width - dragged_region.width; - } + let is_selected = selected_output == Some(disp.output_key); - // Snap-align on y-axis when alignment is near. - if (dragged_region.y - nearest_region.y).abs() <= 8.0 { - dragged_region.y = nearest_region.y; - } + let bg_color = if is_selected { + cosmic_theme.accent_color() + } else { + cosmic_theme.palette.neutral_4 + }; - // Snap-align on y-axis when alignment is near bottom edge. - if ((dragged_region.y + dragged_region.height) - (nearest_region.y + nearest_region.height)) - .abs() - <= 8.0 - { - dragged_region.y = nearest_region.y + nearest_region.height - dragged_region.height; - } + frame.fill( + &Path::rounded_rectangle(screen_pos, screen_size, DISPLAY_CORNER_RADIUS.into()), + Color::from(bg_color), + ); - // Prevent display from overlapping with other displays. - for (other_output, other_region) in display_regions(model, list, bounds, max_dimensions) { - if other_output == output { - continue; - } + frame.stroke( + &Path::rounded_rectangle(screen_pos, screen_size, DISPLAY_CORNER_RADIUS.into()), + Stroke::default() + .with_width(DISPLAY_BORDER_WIDTH) + .with_color(Color::from(border_color)), + ); - if other_region.intersects(&dragged_region) { - return; + let badge_pos = Point::new( + screen_pos.x + (screen_size.width - BADGE_WIDTH) / 2.0, + screen_pos.y + (screen_size.height - BADGE_HEIGHT) / 2.0, + ); + let badge_size = Size::new(BADGE_WIDTH, BADGE_HEIGHT); + + frame.fill( + &Path::rounded_rectangle(badge_pos, badge_size, BADGE_CORNER_RADIUS.into()), + Color::from(cosmic_theme.palette.neutral_1), + ); + + frame.fill_text(Text { + content: (index + 1).to_string(), + position: Point::new( + badge_pos.x + BADGE_WIDTH / 2.0, + badge_pos.y + BADGE_HEIGHT / 2.0, + ), + color: Color::from(cosmic_theme.palette.neutral_10), + size: BADGE_FONT_SIZE.into(), + font: cosmic::font::bold(), + horizontal_alignment: cosmic::iced::alignment::Horizontal::Center, + vertical_alignment: cosmic::iced::alignment::Vertical::Center, + ..Default::default() + }); } + + vec![frame.into_geometry()] } - *region = dragged_region; -} + fn update( + &self, + state: &mut Self::State, + event: canvas_event::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> (Status, Option) { + let has_active_interaction = !matches!(state.interaction, Interaction::None); -fn is_landscape(transform: Transform) -> bool { - matches!( - transform, - Transform::Normal | Transform::Rotate180 | Transform::Flipped | Transform::Flipped180 - ) -} + let cursor_pos = if has_active_interaction { + cursor + .position() + .map(|pos| Point::new(pos.x - bounds.x, pos.y - bounds.y)) + } else { + cursor.position_in(bounds) + }; -#[derive(Debug)] -enum NearestSide { - East, - North, - South, - West, -} + let Some(cursor_pos) = cursor_pos else { + return (Status::Ignored, None); + }; -fn distance(a: Point, b: Point) -> f32 { - ((b.x - a.x).powf(2.0) + (b.y - a.y).powf(2.0)).sqrt() -} + match event { + canvas_event::Event::Mouse(mouse::Event::ButtonPressed(button)) => match button { + mouse::Button::Left => { + let world_pos = state + .camera + .borrow() + .screen_to_world(cursor_pos, bounds.size()); + if let Some(idx) = state.find_display_at(world_pos) { + let displays = state.displays.borrow(); + let display = &displays[idx]; + let display_key = display.output_key; + let display_position = display.position; + drop(displays); + + state.interaction = Interaction::Dragging { + output_key: display_key, + offset: Vector::new( + world_pos.x - display_position.x, + world_pos.y - display_position.y, + ), + }; -fn east_point(r: &Rectangle) -> Point { - Point { - x: r.x, - y: r.center_y(), - } -} + if let Some(ref on_select) = self.on_select { + return (Status::Captured, Some(on_select(display_key))); + } + return (Status::Captured, None); + } + } + mouse::Button::Middle | mouse::Button::Right => { + state.interaction = Interaction::Panning { + last_pos: cursor_pos, + }; + return (Status::Captured, None); + } + _ => {} + }, + + // Handle movement + canvas_event::Event::Mouse(mouse::Event::CursorMoved { .. }) => { + match &state.interaction { + Interaction::Dragging { output_key, offset } => { + let world_pos = state + .camera + .borrow() + .screen_to_world(cursor_pos, bounds.size()); + state.handle_drag_move(*output_key, *offset, world_pos, bounds.size()); + return (Status::Captured, None); + } + Interaction::Panning { last_pos } => { + let delta = + Vector::new(cursor_pos.x - last_pos.x, cursor_pos.y - last_pos.y); + state.camera.borrow_mut().pan(delta); + state.interaction = Interaction::Panning { + last_pos: cursor_pos, + }; + return (Status::Captured, None); + } + Interaction::None => {} + } + } -fn north_point(r: &Rectangle) -> Point { - Point { - x: r.center_x(), - y: r.y, - } -} + canvas_event::Event::Mouse(mouse::Event::ButtonReleased(button)) => { + if matches!( + button, + mouse::Button::Left | mouse::Button::Middle | mouse::Button::Right + ) { + if let Interaction::Dragging { output_key, .. } = state.interaction { + state.interaction = Interaction::None; + + let displays = state.displays.borrow(); + if let Some(display) = displays.iter().find(|d| d.output_key == output_key) + { + let new_pos = ( + display.position.x as i32, + display.position.y as i32, + ); + + let old_pos = self + .list + .outputs + .get(output_key) + .map(|output| output.position) + .unwrap_or((0, 0)); + + if old_pos != new_pos { + *state.needs_fit.borrow_mut() = true; + + if let Some(ref on_placement) = self.on_placement { + return ( + Status::Captured, + Some(on_placement(output_key, new_pos.0, new_pos.1)), + ); + } + } + } + return (Status::Captured, None); + } -fn west_point(r: &Rectangle) -> Point { - Point { - x: r.x + r.width, - y: r.center_y(), - } -} + state.interaction = Interaction::None; + return (Status::Captured, None); + } + } -fn south_point(r: &Rectangle) -> Point { - Point { - x: r.center_x(), - y: r.y + r.height, + _ => {} + } + + (Status::Ignored, None) } } + +pub type Arrangement<'a, Message> = ArrangementCanvas<'a, Message>; diff --git a/cosmic-settings/src/pages/display/mod.rs b/cosmic-settings/src/pages/display/mod.rs index 4a0db76c..4e7a9cb6 100644 --- a/cosmic-settings/src/pages/display/mod.rs +++ b/cosmic-settings/src/pages/display/mod.rs @@ -2,12 +2,10 @@ // SPDX-License-Identifier: GPL-3.0-only pub mod arrangement; -// pub mod night_light; use crate::{app, pages}; use arrangement::Arrangement; use cosmic::iced::{Alignment, Length, time}; -use cosmic::iced_widget::scrollable::RelativeOffset; use cosmic::widget::{ self, column, container, dropdown, list_column, segmented_button, tab_bar, text, toggler, }; @@ -33,11 +31,6 @@ static DPI_SCALE_LABELS: LazyLock> = #[derive(Clone, Copy, Debug)] pub struct ColorDepth(usize); -// /// Identifies the content to display in the context drawer -// pub enum ContextDrawer { -// NightLight, -// } - /// Display mirroring options #[derive(Clone, Copy, Debug, PartialEq)] pub enum Mirroring { @@ -47,19 +40,6 @@ pub enum Mirroring { Mirror(OutputKey), } -/// Night light preferences -// #[derive(Clone, Copy, Debug)] -// pub enum NightLight { -/// Toggles night light's automatic scheduling. -// AutoSchedule(bool), -/// Sets the night light schedule. -// ManualSchedule, -/// Changes the preferred night light temperature. -// Temperature(f32), -/// Toggles night light mode -// Toggle(bool), -// } - #[derive(Clone, Debug)] pub enum Message { /// Change placement of display @@ -80,14 +60,8 @@ pub enum Message { DisplayToggle(bool), /// Configures mirroring status of a display. Mirroring(Mirroring), - /// Handle night light preferences. - // NightLight(NightLight), - /// Show the night light mode context drawer. - // NightLightContext, /// Set the orientation of a display. Orientation(Transform), - /// Pan the displays view - Pan(arrangement::Pan), /// Status of an applied display change. RandrResult(Arc>), /// Set the refresh rate of a display. @@ -106,6 +80,8 @@ pub enum Message { randr: Arc>, }, Surface(surface::Action), + /// Clear the invalid arrangement error if generation matches + ClearInvalidArrangement(u64), } impl From for app::Message { @@ -136,20 +112,29 @@ pub struct Page { mirror_map: SecondaryMap, mirror_menu: widget::dropdown::multi::Model, active_display: OutputKey, + /// Name of the active display (stable across updates, unlike OutputKey) + active_display_name: Option, randr_handle: Option<(oneshot::Sender<()>, cosmic::iced::task::Handle)>, hotplug_handle: Option<(oneshot::Sender<()>, cosmic::iced::task::Handle)>, config: Config, cache: ViewCache, - // context: Option, - display_arrangement_scrollable: widget::Id, - /// Tracks the last pan status. - last_pan: f32, /// The setting to revert to if the next dialog page is cancelled. dialog: Option, /// the instant the setting was changed. dialog_countdown: usize, show_display_options: bool, adjusted_scale: u32, + /// Set when an invalid display arrangement is attempted. + /// Automatically cleared after 5 seconds or when a valid arrangement is applied. + invalid_arrangement: bool, + /// Generation counter for invalid arrangement errors. + /// Incremented on each validation failure to prevent race conditions where + /// an older timeout clears a newer error message. + invalid_arrangement_generation: u64, + /// Flag to force first display selection on next update. + /// Set to true when entering the page, ensures first display is always selected + /// even when multiple rapid updates occur. + force_first_display: bool, } impl Default for Page { @@ -160,26 +145,25 @@ impl Default for Page { display_tabs: segmented_button::SingleSelectModel::default(), mirror_map: SecondaryMap::new(), mirror_menu: widget::dropdown::multi::model(), - active_display: OutputKey::default(), + active_display: OutputKey::null(), + active_display_name: None, randr_handle: None, hotplug_handle: None, config: Config::default(), cache: ViewCache::default(), - // context: None, - display_arrangement_scrollable: widget::Id::unique(), - last_pan: 0.5, dialog: None, dialog_countdown: 0, show_display_options: true, adjusted_scale: 0, + invalid_arrangement: false, + invalid_arrangement_generation: 0, + force_first_display: false, } } } #[derive(Default)] struct Config { - /// Whether night light is enabled. - // night_light_enabled: bool, refresh_rate: Option, vrr: Option, resolution: Option<(u32, u32)>, @@ -209,17 +193,6 @@ impl page::Page for Page { sections: &mut SlotMap>, ) -> Option { Some(vec![ - // Night light - // sections.insert( - // Section::default() - // .descriptions(vec![ - // text::NIGHT_LIGHT.as_str().into(), - // text::NIGHT_LIGHT_AUTO.as_str().into(), - // text::NIGHT_LIGHT_DESCRIPTION.as_str().into(), - // ]) - // .view::(move |_binder, page, _section| page.night_light_view()), - // ), - // Display arrangement sections.insert(display_arrangement()), // Display configuration sections.insert(display_configuration()), @@ -236,6 +209,10 @@ impl page::Page for Page { fn on_enter(&mut self) -> Task { use std::time::Duration; + self.force_first_display = true; + self.active_display = OutputKey::null(); + self.active_display_name = None; + self.cache.orientations = [ fl!("orientation", "standard"), fl!("orientation", "rotate-90"), @@ -537,7 +514,9 @@ impl Page { } } - Message::Display(display) => self.set_display(display), + Message::Display(display_entity) => { + self.set_display(display_entity); + } Message::ColorDepth(color_depth) => return self.set_color_depth(color_depth), @@ -562,36 +541,15 @@ impl Page { }; return self.exec_randr(output, Randr::Mirror(self.active_display)); - } // Mirroring::ProjectToAll => (), + } }, - // Message::NightLight(night_light) => {} - // - // Message::NightLightContext => { - // self.context = Some(ContextDrawer::NightLight); - // return cosmic::task::message(app::Message::OpenContextDrawer( - // text::NIGHT_LIGHT.clone().into(), - // )); - // } Message::Orientation(orientation) => return self.set_orientation(orientation), - Message::Pan(pan) => { - match pan { - arrangement::Pan::Left => self.last_pan = 0.0f32.max(self.last_pan - 0.01), - arrangement::Pan::Right => self.last_pan = 1.0f32.min(self.last_pan + 0.01), - } - - return cosmic::iced::widget::scrollable::snap_to( - self.display_arrangement_scrollable.clone(), - RelativeOffset { - x: self.last_pan, - y: 0.0, - }, - ); + Message::Position(output_key, x, y) => { + return self.set_position(output_key, x, y); } - Message::Position(display, x, y) => return self.set_position(display, x, y), - Message::RefreshRate(rate) => return self.set_refresh_rate(rate), Message::VariableRefreshRate(mode) => return self.set_vrr(mode), @@ -623,7 +581,7 @@ impl Page { tracing::error!(why = why.to_string(), "error fetching displays"); } - None => (), + None => {} } self.refreshing_page.store(false, Ordering::SeqCst); @@ -632,29 +590,35 @@ impl Page { Message::Surface(a) => { return cosmic::task::message(crate::app::Message::Surface(a)); } + + Message::ClearInvalidArrangement(generation) => { + // Only clear if this is still the current error generation (prevents race conditions) + if self.invalid_arrangement_generation == generation { + self.invalid_arrangement = false; + tracing::debug!( + "Cleared invalid arrangement error (generation {})", + generation + ); + } + } } - self.last_pan = 0.5; - cosmic::iced::widget::scrollable::snap_to( - self.display_arrangement_scrollable.clone(), - RelativeOffset { x: 0.5, y: 0.5 }, - ) + Task::none() } - // /// Displays the night light context drawer. - // pub fn night_light_context_view(&self) -> Element { - // column().into() - // } - - /// Reloads the display list, and all information relevant to the active display. pub fn update_displays(&mut self, list: List) { - let active_display_name = self - .display_tabs - .text_remove(self.display_tabs.active()) - .unwrap_or_default(); + let force_first = self.force_first_display; + if force_first { + self.force_first_display = false; + } + + let previous_active_name = self.active_display_name.clone(); + let mut active_tab_pos: u16 = 0; + let mut found_previous_active = false; self.active_display = OutputKey::null(); + self.active_display_name = None; self.display_tabs = Default::default(); self.mirror_map = SecondaryMap::new(); self.list = list; @@ -666,6 +630,8 @@ impl Page { .map(|(key, output)| (&*output.name, key)) .collect::>(); + let mut target_entity = None; + for (pos, (_name, id)) in sorted_outputs.into_iter().enumerate() { let Some(output) = self.list.outputs.get(id) else { continue; @@ -682,17 +648,45 @@ impl Page { let text = crate::utils::display_name(&output.name, output.physical); - if text == active_display_name { + let is_previous_active = previous_active_name + .as_ref() + .map(|prev_name| &output.name == prev_name) + .unwrap_or(false); + + if is_previous_active { active_tab_pos = pos as u16; + found_previous_active = true; } - self.display_tabs.insert().text(text).data::(id); - } + let entity = self + .display_tabs + .insert() + .text(text) + .data::(id) + .id(); - self.display_tabs.activate_position(active_tab_pos); + if is_previous_active { + target_entity = Some((entity, id, output.name.clone())); + } + } - // Retrieve data for the first, activated display. - self.set_display(self.display_tabs.active()); + if force_first { + self.display_tabs.activate_position(0); + let active_entity = self.display_tabs.active(); + self.set_display(active_entity); + } else if !found_previous_active || previous_active_name.is_none() { + self.display_tabs.activate_position(active_tab_pos); + let active_entity = self.display_tabs.active(); + self.set_display(active_entity); + } else if let Some((entity, output_key, display_name)) = target_entity { + self.display_tabs.activate(entity); + self.active_display = output_key; + self.active_display_name = Some(display_name.clone()); + } else { + self.display_tabs.activate_position(active_tab_pos); + let active_entity = self.display_tabs.active(); + self.set_display(active_entity); + } } /// Sets the dialog to be shown to the user. Will not show a dialog if the @@ -719,9 +713,8 @@ impl Page { unimplemented!() } - /// Changes the active display, and regenerates available options for it. - pub fn set_display(&mut self, display: segmented_button::Entity) { - let Some(&output_id) = self.display_tabs.data::(display) else { + pub fn set_display(&mut self, display_entity: segmented_button::Entity) { + let Some(&output_id) = self.display_tabs.data::(display_entity) else { return; }; @@ -729,8 +722,11 @@ impl Page { return; }; - self.display_tabs.activate(display); + if self.display_tabs.active() != display_entity { + self.display_tabs.activate(display_entity); + } self.active_display = output_id; + self.active_display_name = Some(output.name.clone()); self.config.refresh_rate = None; self.config.resolution = None; self.config.vrr = output.adaptive_sync; @@ -908,9 +904,215 @@ impl Page { Task::batch(tasks) } - /// Changes the position of the display. - pub fn set_position(&mut self, display: OutputKey, x: i32, y: i32) -> Task { - let Some(output) = self.list.outputs.get_mut(display) else { + fn is_landscape(transform: Transform) -> bool { + matches!( + transform, + Transform::Normal | Transform::Rotate180 | Transform::Flipped | Transform::Flipped180 + ) + } + + fn validate_arrangement(&self, output_key: OutputKey, new_x: i32, new_y: i32) -> bool { + let Some(moving_output) = self.list.outputs.get(output_key) else { + return false; + }; + + // Only validate enabled displays + if !moving_output.enabled { + return true; + } + + let Some(mode) = moving_output + .current + .and_then(|key| self.list.modes.get(key)) + else { + return false; + }; + + let (moving_width, moving_height) = if moving_output.transform.is_none_or(Self::is_landscape) { + (mode.size.0, mode.size.1) + } else { + (mode.size.1, mode.size.0) + }; + + let moving_width = (moving_width as f64 / moving_output.scale) as u32; + let moving_height = (moving_height as f64 / moving_output.scale) as u32; + + // Check for overlaps with other displays + for (other_key, other_output) in &self.list.outputs { + if other_key == output_key || !other_output.enabled { + continue; + } + + let Some(other_mode) = other_output + .current + .and_then(|key| self.list.modes.get(key)) + else { + continue; + }; + + let (other_width, other_height) = if other_output.transform.is_none_or(Self::is_landscape) { + (other_mode.size.0, other_mode.size.1) + } else { + (other_mode.size.1, other_mode.size.0) + }; + + let other_width = (other_width as f64 / other_output.scale) as u32; + let other_height = (other_height as f64 / other_output.scale) as u32; + + // Check for rectangle overlap + let x_overlap = new_x < other_output.position.0 + other_width as i32 + && new_x + moving_width as i32 > other_output.position.0; + let y_overlap = new_y < other_output.position.1 + other_height as i32 + && new_y + moving_height as i32 > other_output.position.1; + + if x_overlap && y_overlap { + return false; + } + } + + self.validate_connectivity(output_key, new_x, new_y) + } + + /// Check if two display rectangles are adjacent (sharing an edge). + fn are_displays_adjacent( + rect1: &(OutputKey, i32, i32, u32, u32), + rect2: &(OutputKey, i32, i32, u32, u32), + ) -> bool { + const EDGE_TOLERANCE: i32 = arrangement::EDGE_TOLERANCE as i32; + + let (_, x1, y1, w1, h1) = *rect1; + let (_, x2, y2, w2, h2) = *rect2; + + let right1 = x1 + w1 as i32; + let bottom1 = y1 + h1 as i32; + let right2 = x2 + w2 as i32; + let bottom2 = y2 + h2 as i32; + + // Vertical adjacency (left-right touching) + let left_touches_right = (right1 - x2).abs() <= EDGE_TOLERANCE; + let right_touches_left = (right2 - x1).abs() <= EDGE_TOLERANCE; + let vertical_overlap = bottom1 > y2 && y1 < bottom2; + + if (left_touches_right || right_touches_left) && vertical_overlap { + return true; + } + + // Horizontal adjacency (top-bottom touching) + let bottom_touches_top = (bottom1 - y2).abs() <= EDGE_TOLERANCE; + let top_touches_bottom = (bottom2 - y1).abs() <= EDGE_TOLERANCE; + let horizontal_overlap = right1 > x2 && x1 < right2; + + (bottom_touches_top || top_touches_bottom) && horizontal_overlap + } + + fn validate_connectivity( + &self, + moved_output_key: OutputKey, + moved_x: i32, + moved_y: i32, + ) -> bool { + use std::collections::{HashMap, HashSet, VecDeque}; + + // Collect all enabled display rectangles + let display_rects: Vec<_> = self + .list + .outputs + .iter() + .filter(|(_, output)| output.enabled) + .filter_map(|(key, output)| { + let mode = self.list.modes.get(output.current?)?; + let (width, height) = if output.transform.is_none_or(Self::is_landscape) { + (mode.size.0, mode.size.1) + } else { + (mode.size.1, mode.size.0) + }; + + let scaled_width = (width as f64 / output.scale) as u32; + let scaled_height = (height as f64 / output.scale) as u32; + + let (x, y) = if key == moved_output_key { + (moved_x, moved_y) + } else { + output.position + }; + + Some((key, x, y, scaled_width, scaled_height)) + }) + .collect(); + + // Single display is always connected + if display_rects.len() <= 1 { + return true; + } + + // Build adjacency graph + let mut adjacency: HashMap> = display_rects + .iter() + .map(|(key, ..)| (*key, Vec::new())) + .collect(); + + for i in 0..display_rects.len() { + for j in (i + 1)..display_rects.len() { + if Self::are_displays_adjacent(&display_rects[i], &display_rects[j]) { + let key_i = display_rects[i].0; + let key_j = display_rects[j].0; + adjacency.get_mut(&key_i).unwrap().push(key_j); + adjacency.get_mut(&key_j).unwrap().push(key_i); + } + } + } + + // BFS to check connectivity + let mut visited = HashSet::new(); + let mut queue = VecDeque::from([display_rects[0].0]); + visited.insert(display_rects[0].0); + + while let Some(current) = queue.pop_front() { + if let Some(neighbors) = adjacency.get(¤t) { + for &neighbor in neighbors { + if visited.insert(neighbor) { + queue.push_back(neighbor); + } + } + } + } + + let all_connected = visited.len() == display_rects.len(); + + if !all_connected { + tracing::debug!( + ?moved_output_key, + moved_x, + moved_y, + total_displays = display_rects.len(), + connected_displays = visited.len(), + "Display arrangement is disconnected" + ); + } + + all_connected + } + + pub fn set_position(&mut self, output_key: OutputKey, x: i32, y: i32) -> Task { + if !self.list.outputs.contains_key(output_key) { + return Task::none(); + } + + if !self.validate_arrangement(output_key, x, y) { + self.invalid_arrangement = true; + self.invalid_arrangement_generation = + self.invalid_arrangement_generation.wrapping_add(1); + let generation = self.invalid_arrangement_generation; + + return cosmic::task::future(async move { + tokio::time::sleep(cosmic::iced::time::Duration::from_secs(5)).await; + app::Message::from(Message::ClearInvalidArrangement(generation)) + }); + } + + self.invalid_arrangement = false; + + let Some(output) = self.list.outputs.get_mut(output_key) else { return Task::none(); }; @@ -921,7 +1123,7 @@ impl Page { return Task::none(); } - let output = &self.list.outputs[display]; + let output = &self.list.outputs[output_key]; self.exec_randr(output, Randr::Position(x, y)) } @@ -1175,23 +1377,41 @@ pub fn display_arrangement() -> Section { column() .push( - text::body(&descriptions[display_arrangement_desc]) - .apply(container) - .padding([space_xxs, space_m]), + // Header row with description on left and error message on right + cosmic::iced::widget::row![ + text::body(&descriptions[display_arrangement_desc]), + cosmic::iced::widget::horizontal_space(), + if page.invalid_arrangement { + text::body(fl!("invalid-arrangement")) + } else { + text::body("") + } + ] + .align_y(Alignment::Center) + .apply(container) + .padding([space_xxs, space_m]), ) - .push({ + .push( Arrangement::new(&page.list, &page.display_tabs) - .on_select(|id| pages::Message::Displays(Message::Display(id))) - .on_pan(|pan| pages::Message::Displays(Message::Pan(pan))) .on_placement(|id, x, y| { pages::Message::Displays(Message::Position(id, x, y)) }) - .apply(widget::scrollable::horizontal) - .id(page.display_arrangement_scrollable.clone()) - .width(Length::Shrink) - .apply(container) - .center_x(Length::Fill) - }) + .on_select(|output_key| { + // Find the entity that corresponds to this OutputKey + let entity = page + .display_tabs + .iter() + .find(|&entity| { + page.display_tabs + .data::(entity) + .map(|&key| key == output_key) + .unwrap_or(false) + }) + .unwrap_or(page.display_tabs.active()); + pages::Message::Displays(Message::Display(entity)) + }) + .view(), + ) .apply(container) .class(cosmic::theme::Container::List) .width(Length::Fill) diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index f3c6d403..1cb4615b 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -420,6 +420,8 @@ display = Displays .scale = Scale .additional-scale-options = Additional scale options +invalid-arrangement = Invalid arrangement. Restored previous valid state. + mirroring = Mirroring .id = Mirroring { $id } .dont = Don't mirror From a22f954cefc9b2ff9afb46c58b2d4a8ba53a1469 Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Wed, 19 Nov 2025 01:55:09 +0100 Subject: [PATCH 2/2] apply rustfmt --- .../src/pages/display/arrangement.rs | 14 ++++-------- cosmic-settings/src/pages/display/mod.rs | 22 ++++++++++--------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/cosmic-settings/src/pages/display/arrangement.rs b/cosmic-settings/src/pages/display/arrangement.rs index 3d13ad3e..9f692fce 100644 --- a/cosmic-settings/src/pages/display/arrangement.rs +++ b/cosmic-settings/src/pages/display/arrangement.rs @@ -28,7 +28,6 @@ const SCALE_THRESHOLD: f32 = 0.05; const CENTER_THRESHOLD: f32 = 50.0; pub(super) const EDGE_TOLERANCE: f32 = 1.0; - #[derive(Debug, Clone)] pub struct Camera2D { pub position: Point, @@ -192,10 +191,7 @@ impl ArrangementState { list.outputs .get(disp.output_key) .map(|output| { - let randr_pos = Point::new( - output.position.0 as f32, - output.position.1 as f32, - ); + let randr_pos = Point::new(output.position.0 as f32, output.position.1 as f32); randr_pos != disp.position }) .unwrap_or(false) @@ -287,7 +283,8 @@ impl ArrangementState { fn would_overlap_any(&self, dragged_key: OutputKey, position: Point, size: Size) -> bool { self.displays.borrow().iter().any(|other| { - other.output_key != dragged_key && Self::rectangles_overlap(position, size, other.bounds()) + other.output_key != dragged_key + && Self::rectangles_overlap(position, size, other.bounds()) }) } @@ -747,10 +744,7 @@ impl<'a, Message: Clone> Program for ArrangementCanvas let displays = state.displays.borrow(); if let Some(display) = displays.iter().find(|d| d.output_key == output_key) { - let new_pos = ( - display.position.x as i32, - display.position.y as i32, - ); + let new_pos = (display.position.x as i32, display.position.y as i32); let old_pos = self .list diff --git a/cosmic-settings/src/pages/display/mod.rs b/cosmic-settings/src/pages/display/mod.rs index 4e7a9cb6..f1a1edc7 100644 --- a/cosmic-settings/src/pages/display/mod.rs +++ b/cosmic-settings/src/pages/display/mod.rs @@ -928,11 +928,12 @@ impl Page { return false; }; - let (moving_width, moving_height) = if moving_output.transform.is_none_or(Self::is_landscape) { - (mode.size.0, mode.size.1) - } else { - (mode.size.1, mode.size.0) - }; + let (moving_width, moving_height) = + if moving_output.transform.is_none_or(Self::is_landscape) { + (mode.size.0, mode.size.1) + } else { + (mode.size.1, mode.size.0) + }; let moving_width = (moving_width as f64 / moving_output.scale) as u32; let moving_height = (moving_height as f64 / moving_output.scale) as u32; @@ -950,11 +951,12 @@ impl Page { continue; }; - let (other_width, other_height) = if other_output.transform.is_none_or(Self::is_landscape) { - (other_mode.size.0, other_mode.size.1) - } else { - (other_mode.size.1, other_mode.size.0) - }; + let (other_width, other_height) = + if other_output.transform.is_none_or(Self::is_landscape) { + (other_mode.size.0, other_mode.size.1) + } else { + (other_mode.size.1, other_mode.size.0) + }; let other_width = (other_width as f64 / other_output.scale) as u32; let other_height = (other_height as f64 / other_output.scale) as u32;