// Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 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 cosmic::iced_core::{alignment, event, text}; use cosmic::iced_core::{layout, mouse, renderer, touch, Point}; use cosmic::widget::segmented_button::{self, SingleSelectModel}; use cosmic::Renderer; use cosmic_randr_shell::{self as randr, OutputKey}; use randr::Transform; const UNIT_PIXELS: f32 = 12.0; pub type OnPlacementFunc = Box Message>; pub type OnSelectFunc = Box Message>; #[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_placement: Option>, #[setters(skip)] on_select: Option>, width: Length, height: Length, } impl<'a, Message> Arrangement<'a, Message> { pub fn new(list: &'a randr::List, tab_model: &'a SingleSelectModel) -> Self { Self { list, tab_model, on_placement: None, on_select: None, width: Length::Shrink, height: Length::Shrink, } } 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 } 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 } } impl<'a, Message: Clone> Widget for Arrangement<'a, Message> { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { tree::State::new(State::default()) } fn size(&self) -> Size { Size { width: self.width, height: self.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 max_dimensions = (0, 0); let mut display_area = (0, 0); for output in self.list.outputs.values() { if !output.enabled { continue; } let Some(mode_key) = output.current else { continue; }; let Some(mode) = self.list.modes.get(mode_key) else { continue; }; let (mut width, mut height) = if output.transform.map_or(true, is_landscape) { (mode.size.0, mode.size.1) } else { (mode.size.1, mode.size.0) }; // 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; max_dimensions.0 = max_dimensions.0.max(width); max_dimensions.1 = max_dimensions.1.max(height); 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); } let width = (max_dimensions.0 as i32 * 2 + display_area.0) as f32 / UNIT_PIXELS; let height = (max_dimensions.1 as i32 * 2 + display_area.1) as f32 / UNIT_PIXELS; let state = tree.state.downcast_mut::(); state.max_dimensions = ( max_dimensions.0 as f32 * 1.25 / UNIT_PIXELS, max_dimensions.1 as f32 * 1.25 / UNIT_PIXELS, ); let limits = limits .width(Length::Fixed(width)) .height(Length::Fixed(height)); let size = limits.resolve(width, height, Size::ZERO); layout::Node::new(size) } 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(); match event { core::Event::Mouse(mouse::Event::CursorMoved { .. }) | core::Event::Touch(touch::Event::FingerMoved { .. }) => { if let Some(position) = cursor.position() { let state = tree.state.downcast_mut::(); if let Some((output_key, region)) = state.dragging.as_mut() { update_dragged_region( self.tab_model, self.list, &bounds, *output_key, region, state.max_dimensions, (position.x - state.offset.0, position.y - state.offset.1), ); return event::Status::Captured; } } } 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; } } } 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() { if 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) { if key == output_key { shell.publish(on_select(id)); } } } } return event::Status::Captured; } } 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 - bounds.y) * UNIT_PIXELS) as i32, )); } return event::Status::Captured; } } _ => (), } event::Status::Ignored } fn mouse_interaction( &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; } } mouse::Interaction::Idle } fn draw( &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::(); let bounds = layout.bounds(); let theme = cosmic::theme::active(); let cosmic_theme = theme.cosmic(); let border_color = cosmic_theme.palette.neutral_7; let active_key = self.tab_model.active_data::(); 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 { if dragged_key == output_key { region = dragged_region; } } let (background, border_color) = if Some(&output_key) == active_key { let mut border_color = border_color; border_color.alpha = 0.4; (cosmic_theme.accent_color(), border_color) } else { (cosmic_theme.palette.neutral_4, border_color) }; 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 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, }; 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()), ); core::text::Renderer::fill_text( renderer, core::Text { content: itoa::Buffer::new().format(id), size: core::Pixels(24.0), line_height: core::text::LineHeight::Relative(1.2), font: cosmic::font::FONT_BOLD, bounds: id_bounds.size(), horizontal_alignment: alignment::Horizontal::Center, vertical_alignment: alignment::Vertical::Center, shaping: text::Shaping::Basic, }, core::Point { x: id_bounds.center_x(), y: id_bounds.center_y(), }, cosmic_theme.palette.neutral_10.into(), *viewport, ); } } } impl<'a, Message: 'static + Clone> From> for cosmic::Element<'a, Message> { fn from(display_positioner: Arrangement<'a, Message>) -> Self { Element::new(display_positioner) } } #[derive(Default)] struct State { drag_from: Point, dragging: Option<(OutputKey, Rectangle)>, offset: (f32, f32), max_dimensions: (f32, f32), } /// 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 Some(output) = list.outputs.get(key) else { return None; }; if !output.enabled { return None; } let Some(mode_key) = output.current else { return None; }; let Some(mode) = list.modes.get(mode_key) else { return None; }; 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, ); (width, height) = if output.transform.map_or(true, is_landscape) { (width, height) } else { (height, width) }; Some(( key, Rectangle { width, height, x: max_dimensions.0 + bounds.x + (output.position.0 as f32) / UNIT_PIXELS, y: max_dimensions.1 + bounds.y + (output.position.1 as f32) / UNIT_PIXELS, }, )) }) } 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)); } } None } /// 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; } let center = dragged_region.center(); let eastward = distance(east_point(&other_region), center) * 1.5; let westward = distance(west_point(&other_region), center) * 1.5; let northward = distance(north_point(&other_region), center); let southward = distance(south_point(&other_region), center); let mut nearer = false; if nearest > eastward { (nearest, nearest_side, nearer) = (eastward, NearestSide::East, true); } if nearest > westward { (nearest, nearest_side, nearer) = (westward, NearestSide::West, true); } if nearest > northward { (nearest, nearest_side, nearer) = (northward, NearestSide::North, true); } if nearest > southward { (nearest, nearest_side, nearer) = (southward, NearestSide::South, true); } if nearer { nearest_region = other_region; } } // 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); } 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); } 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); } 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); } } // 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; } // 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; } // 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; } // 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; } // 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; } if other_region.intersects(&dragged_region) { return; } } *region = dragged_region; } fn is_landscape(transform: Transform) -> bool { matches!( transform, Transform::Normal | Transform::Rotate180 | Transform::Flipped | Transform::Flipped180 ) } #[derive(Debug)] enum NearestSide { East, North, South, West, } fn distance(a: Point, b: Point) -> f32 { ((b.x - a.x).powf(2.0) + (b.y - a.y).powf(2.0)).sqrt() } fn east_point(r: &Rectangle) -> Point { Point { x: r.x, y: r.center_y(), } } fn north_point(r: &Rectangle) -> Point { Point { x: r.center_x(), y: r.y, } } fn west_point(r: &Rectangle) -> Point { Point { x: r.x + r.width, y: r.center_y(), } } fn south_point(r: &Rectangle) -> Point { Point { x: r.center_x(), y: r.y + r.height, } }