From d328655f8b360f125075e3a6326ec834f960a462 Mon Sep 17 00:00:00 2001 From: Julian Orth Date: Sat, 7 Mar 2026 14:04:04 +0100 Subject: [PATCH] control-center: add outputs pane --- src/compositor.rs | 3 +- src/config/handler.rs | 2 +- src/control_center.rs | 9 +- src/control_center/cc_outputs.rs | 1711 +++++++++++++++++++++++++++++ src/control_center/cc_sidebar.rs | 5 + src/egui_adapter/egui_oklch.rs | 1 - src/egui_adapter/egui_platform.rs | 2 - src/ifs/head_management.rs | 8 +- src/ifs/jay_randr.rs | 2 +- src/output_schedule.rs | 15 +- src/state.rs | 28 +- src/tasks/connector.rs | 14 +- src/tree/output.rs | 11 + 13 files changed, 1775 insertions(+), 36 deletions(-) create mode 100644 src/control_center/cc_outputs.rs diff --git a/src/compositor.rs b/src/compositor.rs index 30909c7e..2e10d5bb 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -744,8 +744,7 @@ fn create_dummy_output(state: &Rc) { wlr_output_heads: Default::default(), }); let schedule = Rc::new(OutputSchedule::new( - &state.ring, - &state.eng, + state, &connector_data, &persistent_state, )); diff --git a/src/config/handler.rs b/src/config/handler.rs index 77dd9a68..4f39c905 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1484,7 +1484,7 @@ impl ConfigProxyHandler { match connector { Some(c) => { let connector = self.get_output_node(c)?; - connector.schedule.set_cursor_hz(hz); + connector.schedule.set_cursor_hz(&self.state, hz); } _ => { let Some((hz, _)) = map_cursor_hz(hz) else { diff --git a/src/control_center.rs b/src/control_center.rs index 4f27a2a6..1b766656 100644 --- a/src/control_center.rs +++ b/src/control_center.rs @@ -2,7 +2,7 @@ use { crate::{ control_center::{ cc_color_management::ColorManagementPane, cc_compositor::CompositorPane, - cc_idle::IdlePane, cc_xwayland::XwaylandPane, + cc_idle::IdlePane, cc_outputs::OutputsPane, cc_xwayland::XwaylandPane, }, egui_adapter::egui_platform::{ EggError, EggWindow, EggWindowOwner, @@ -36,6 +36,7 @@ use { mod cc_color_management; mod cc_compositor; mod cc_idle; +mod cc_outputs; mod cc_sidebar; mod cc_xwayland; @@ -74,6 +75,7 @@ bitflags! { CCI_IDLE, CCI_COLOR_MANAGEMENT, CCI_XWAYLAND, + CCI_OUTPUTS, } pub struct ControlCenter { @@ -118,6 +120,7 @@ enum PaneType { Idle(IdlePane), ColorManagement(ColorManagementPane), Xwayland(XwaylandPane), + Outputs(Box), } struct CcBehavior<'a> { @@ -140,6 +143,7 @@ impl Pane { PaneType::Idle(v) => v.title(res), PaneType::ColorManagement(v) => v.title(res), PaneType::Xwayland(v) => v.title(res), + PaneType::Outputs(v) => v.title(res), } } @@ -149,6 +153,7 @@ impl Pane { PaneType::Idle(p) => p.show(ui), PaneType::ColorManagement(p) => p.show(ui), PaneType::Xwayland(p) => p.show(behavior, ui), + PaneType::Outputs(p) => p.show(&mut self.ps, ui), } } } @@ -160,6 +165,7 @@ impl PaneType { PaneType::Idle(_) => CCI_IDLE, PaneType::ColorManagement(_) => CCI_COLOR_MANAGEMENT, PaneType::Xwayland(_) => CCI_XWAYLAND, + PaneType::Outputs(_) => CCI_OUTPUTS, } } } @@ -415,7 +421,6 @@ fn icon_label(icon: &str) -> Label { Label::new(icon).selectable(false) } -#[expect(dead_code)] fn grid_label(ui: &mut Ui, label: &str) { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.label(label); diff --git a/src/control_center/cc_outputs.rs b/src/control_center/cc_outputs.rs new file mode 100644 index 00000000..fc3159be --- /dev/null +++ b/src/control_center/cc_outputs.rs @@ -0,0 +1,1711 @@ +use { + crate::{ + backend::{ + BackendColorSpace, BackendEotfs, ConnectorId, Mode, + transaction::{ + BackendConnectorTransactionError, ConnectorTransaction, + PreparedConnectorTransaction, + }, + }, + cmm::cmm_luminance::Luminance, + compositor::{MAX_EXTENTS, MAX_SCALE, MIN_SCALE}, + control_center::{ControlCenterInner, GridExt, PaneState, grid, grid_label, label, tip}, + egui_adapter::{ + egui_oklch::Color32Ext, + egui_platform::icons::{ICON_ADD, ICON_REMOVE}, + }, + ifs::{ + head_management::{HeadName, HeadState, ReadOnlyHeadState}, + wl_output::BlendSpace, + }, + scale::{SCALE_BASE, SCALE_BASEF, Scale}, + state::State, + tree::{TearingMode, Transform, VrrMode}, + utils::errorfmt::ErrorFmt, + }, + ahash::AHashMap, + egui::{ + Align, Button, Checkbox, Color32, ComboBox, DragValue, EventFilter, FontId, Frame, Grid, + Id, Key, Layout, PointerButton, Rect, ScrollArea, Sense, Shadow, Stroke, StrokeKind, Style, + TextFormat, Ui, UiBuilder, Vec2, Widget, WidgetText, pos2, text::LayoutJob, vec2, + }, + egui_tiles::{ + Behavior, Container, Linear, LinearDir, ResizeState, SimplificationOptions, Tile, TileId, + Tiles, Tree, UiResponse, + }, + linearize::{Linearize, LinearizeExt}, + rand::random, + serde::{Deserialize, Serialize}, + std::{ + cell::{Cell, Ref}, + fmt, + rc::Rc, + }, + thiserror::Error, +}; + +pub struct OutputsPane { + tree: Tree, + root_id: TileId, + arrangement_id: Option, + inner: OutputsPaneInner, +} + +struct OutputsPaneInner { + state: Rc, + in_transaction: Cell, + heads: AHashMap, + ui: UiSettings, + settings: Settings, + seed: u64, +} + +enum Pane { + Arrangement, + Settings, +} + +struct CompleteHead { + id: ConnectorId, + name: HeadName, + live_state: ReadOnlyHeadState, + changed_state: Option, + z: u64, + focus: u64, + drag_pos: Option<(f32, f32)>, +} + +struct UiSettings { + scale: f32, + origin: Vec2, + origin_drag: Option, + next_z: u64, + focus: u64, + zoom_to_fit: bool, + view: View, +} + +struct Settings { + show_guide_lines: bool, + snap_to_neighbor: bool, + show_disconnected: bool, + show_disabled: bool, + show_arrangement: bool, + layout: UiLayout, +} + +impl Default for Settings { + fn default() -> Self { + Self { + show_guide_lines: false, + snap_to_neighbor: true, + show_disconnected: false, + show_disabled: true, + show_arrangement: true, + layout: UiLayout::Auto, + } + } +} + +#[derive(Copy, Clone, Serialize, Deserialize, PartialEq, Linearize)] +enum UiLayout { + Auto, + Vertical, + Horizontal, +} + +#[derive(Copy, Clone)] +pub enum View { + Connectors, + Settings, +} + +#[derive(Error, Debug)] +enum HeadTransactionError { + #[error("The connector {} has been removed", .0)] + HeadRemoved(ConnectorId), + #[error("The display connected to connector {} has changed", .0)] + MonitorChanged(ConnectorId), + #[error(transparent)] + Backend(#[from] BackendConnectorTransactionError), +} + +macro_rules! effective { + ($m:expr, $t:expr) => { + $t.as_ref().unwrap_or($m) + }; +} + +macro_rules! modify { + ($m:expr, $t:expr) => { + $t.get_or_insert_with(|| $m.clone()) + }; +} + +impl ControlCenterInner { + pub fn create_outputs_pane(self: &Rc) -> OutputsPane { + let seed = random(); + let mut tiles = Tiles::default(); + let settings_id = tiles.insert_pane(Pane::Settings); + let arrangement_id = tiles.insert_pane(Pane::Arrangement); + let root_id = tiles.insert_container(Linear::new( + LinearDir::Horizontal, + vec![arrangement_id, settings_id], + )); + let tree = Tree::new(Id::new(("cc_outputs", seed)), root_id, tiles); + let mut pane = OutputsPane { + root_id, + arrangement_id: Some(arrangement_id), + tree, + inner: OutputsPaneInner { + state: self.state.clone(), + ui: UiSettings { + scale: 0.1, + origin: Default::default(), + origin_drag: None, + next_z: 0, + focus: 0, + zoom_to_fit: true, + view: View::Connectors, + }, + settings: Default::default(), + in_transaction: Default::default(), + heads: Default::default(), + seed, + }, + }; + pane.inner.reset(); + pane + } +} + +struct B<'a>(&'a mut OutputsPaneInner, &'a mut PaneState); + +impl Behavior for B<'_> { + fn pane_ui(&mut self, ui: &mut Ui, _tile_id: TileId, pane: &mut Pane) -> UiResponse { + Frame::new().inner_margin(5.0).show(ui, |ui| match pane { + Pane::Arrangement => self.0.show_arrangement(ui), + Pane::Settings => self.0.show_main_area(self.1, ui), + }); + UiResponse::None + } + + fn tab_title_for_pane(&mut self, _pane: &Pane) -> WidgetText { + "".into() + } + + fn gap_width(&self, _style: &Style) -> f32 { + 5.0 + } + + fn simplification_options(&self) -> SimplificationOptions { + SimplificationOptions { + prune_empty_tabs: false, + prune_empty_containers: false, + prune_single_child_tabs: false, + prune_single_child_containers: false, + all_panes_must_have_tabs: false, + join_nested_linear_containers: false, + } + } + + fn resize_stroke(&self, style: &Style, resize_state: ResizeState) -> Stroke { + match resize_state { + ResizeState::Idle => style.visuals.widgets.noninteractive.bg_stroke, + ResizeState::Hovering => style.visuals.widgets.hovered.fg_stroke, + ResizeState::Dragging => style.visuals.widgets.active.fg_stroke, + } + } +} + +impl OutputsPane { + pub fn title(&self, res: &mut String) { + res.push_str("Outputs"); + if self.inner.in_transaction.get() { + res.push_str(" (*)"); + } + } + + pub fn show(&mut self, ps: &mut PaneState, ui: &mut Ui) { + self.inner.add_new_heads(); + if let Some(id) = self.arrangement_id { + if !self.inner.settings.show_arrangement { + self.tree.remove_recursively(id); + self.arrangement_id = None; + } + } else { + if self.inner.settings.show_arrangement { + let id = self.tree.tiles.insert_pane(Pane::Arrangement); + self.tree.move_tile_to_container(id, self.root_id, 0, false); + self.arrangement_id = Some(id); + } + } + let show_vertical = match self.inner.settings.layout { + UiLayout::Auto => ui.available_width() < 1024.0, + UiLayout::Vertical => true, + UiLayout::Horizontal => false, + }; + if let Some(root) = self.tree.tiles.get_mut(self.root_id) + && let Tile::Container(root) = root + && let Container::Linear(root) = root + { + root.dir = match show_vertical { + true => LinearDir::Vertical, + false => LinearDir::Horizontal, + }; + } + self.tree.ui(&mut B(&mut self.inner, ps), ui) + } +} + +impl OutputsPaneInner { + fn show_main_area(&mut self, ps: &mut PaneState, ui: &mut Ui) { + ui.scope_builder(UiBuilder::new().id(("main_area", self.seed)), |ui| { + self.show_settings_bar(ps, ui); + ScrollArea::vertical().show(ui, |ui| { + match self.ui.view { + View::Connectors => self.show_connectors(ui), + View::Settings => self.show_settings(ui), + } + ui.allocate_space(ui.available_size()); + }); + }); + } + + fn show_settings_bar(&mut self, ps: &mut PaneState, ui: &mut Ui) { + ui.horizontal_wrapped(|ui| { + if ui.button("Connectors").clicked() { + self.ui.view = View::Connectors; + ui.ctx().request_repaint(); + } + if ui.button("Settings").clicked() { + self.ui.view = View::Settings; + ui.ctx().request_repaint(); + } + if ui + .checkbox(&mut self.ui.zoom_to_fit, "Zoom To Fit") + .changed() + { + ui.ctx().request_repaint(); + } + { + let mut reset = !self.in_transaction.get(); + let reset2 = reset; + let widget = Checkbox::new(&mut reset, "Reset"); + if reset2 { + ui.add_enabled(false, widget); + } else { + if widget.ui(ui).changed() { + self.reset(); + } + } + } + ui.with_layout(Layout::right_to_left(Align::LEFT), |ui| { + let enabled = self.in_transaction.get(); + ui.add_enabled_ui(enabled, |ui| { + if ui.button("Test").clicked() { + if let Err(e) = self.test_transaction() { + ps.errors.push(ErrorFmt(e).to_string()); + } + } + }); + let enabled = self.in_transaction.get(); + ui.add_enabled_ui(enabled, |ui| { + let button = Button::new("Commit").fill(ui.style().visuals.extreme_bg_color); + if ui.add(button).clicked() { + match self.commit_transaction() { + Ok(_) => self.reset(), + Err(e) => { + ps.errors.push(ErrorFmt(e).to_string()); + } + } + } + }); + ui.add_space(ui.available_width()); + }); + }); + ui.separator(); + } + + fn show_connectors(&mut self, ui: &mut Ui) { + let mut heads: Vec<_> = self.heads.values_mut().collect(); + heads.sort_by(|a, b| { + a.live_state + .borrow() + .name + .cmp(&b.live_state.borrow().name) + .then_with(|| a.name.cmp(&b.name)) + }); + let mut is_in_transaction = false; + for head in &mut heads { + show_connector(&self.state, &self.settings, head, ui); + if head.changed_state.is_some() { + is_in_transaction = true; + } + } + self.in_transaction.set(is_in_transaction); + } + + fn show_settings(&mut self, ui: &mut Ui) { + let mut changed = false; + + { + changed |= ui + .checkbox(&mut self.settings.show_guide_lines, "Show guide lines") + .changed(); + } + + { + ui.horizontal(|ui| { + changed |= ui + .checkbox(&mut self.settings.snap_to_neighbor, "Snap to neighbor") + .changed(); + tip(ui, |ui| { + ui.label("Hold Shift to invert this"); + }); + }); + } + + { + ui.checkbox(&mut self.settings.show_arrangement, "Show arrangement area"); + } + + { + let layout_text = |l: UiLayout| match l { + UiLayout::Auto => "Auto", + UiLayout::Vertical => "Vertical", + UiLayout::Horizontal => "Horizontal", + }; + changed |= ComboBox::new("layout", "Layout") + .selected_text(layout_text(self.settings.layout)) + .show_ui(ui, |ui| { + for l in UiLayout::variants() { + ui.selectable_value(&mut self.settings.layout, l, layout_text(l)); + } + }) + .response + .changed(); + } + + { + changed |= ui + .checkbox( + &mut self.settings.show_disconnected, + "Show disconnected heads", + ) + .changed(); + } + + { + changed |= ui + .checkbox(&mut self.settings.show_disabled, "Show disabled heads") + .changed(); + } + + if changed { + ui.ctx().request_repaint(); + } + } + + fn show_arrangement(&mut self, ui: &mut Ui) { + let clip_rect = ui.available_rect_before_wrap(); + let origin = &mut self.ui.origin; + let ox = origin.x.round(); + let oy = origin.y.round(); + let mut heads = vec![]; + let scale = self.ui.scale; + struct PreparedHead<'a> { + name: HeadName, + m: Ref<'a, HeadState>, + changed_state: &'a mut Option, + z: &'a mut u64, + focus: &'a mut u64, + drag_pos: &'a mut Option<(f32, f32)>, + x1: i32, + y1: i32, + x2: i32, + y2: i32, + rect: Rect, + } + for head in self.heads.values_mut() { + let m = head.live_state.borrow(); + let e = effective!(&*m, head.changed_state); + if !e.in_compositor_space { + continue; + } + let (x, y) = e.position; + let (w, h) = e.size; + let x1 = (x as f32 * scale).round() - ox + clip_rect.min.x; + let y1 = (y as f32 * scale).round() - oy + clip_rect.min.y; + let x2 = ((x + w) as f32 * scale).round() - ox + clip_rect.min.x; + let y2 = ((y + h) as f32 * scale).round() - oy + clip_rect.min.y; + heads.push(PreparedHead { + name: head.name, + m, + changed_state: &mut head.changed_state, + z: &mut head.z, + focus: &mut head.focus, + drag_pos: &mut head.drag_pos, + x1: x, + y1: y, + x2: x + w, + y2: y + h, + rect: Rect { + min: pos2(x1, y1), + max: pos2(x2, y2), + }, + }); + } + if self.ui.zoom_to_fit { + let mut x_min = i32::MAX; + let mut x_max = i32::MIN; + let mut y_min = i32::MAX; + let mut y_max = i32::MIN; + for head in &heads { + x_min = x_min.min(head.x1); + x_max = x_max.max(head.x2); + y_min = y_min.min(head.y1); + y_max = y_max.max(head.y2); + } + if x_min > x_max { + x_min = 0; + x_max = 0; + } + if y_min > y_max { + y_min = 0; + y_max = 0; + } + x_min -= 100; + y_min -= 100; + x_max += 100; + y_max += 100; + let dx = x_max - x_min + 1; + let dy = y_max - y_min + 1; + let x_scale = clip_rect.width() / dx as f32; + let y_scale = clip_rect.height() / dy as f32; + let new_scale = x_scale.min(y_scale); + let new_ox = x_min as f32 * new_scale; + let new_oy = y_min as f32 * new_scale; + if new_scale != scale || new_ox != ox || new_oy != oy { + self.ui.scale = new_scale; + origin.x = new_ox; + origin.y = new_oy; + ui.ctx().request_repaint(); + } + } + heads.sort_by_key(|h| *h.z); + let style = &ui.style().visuals; + let mut bg_color = style.panel_fill.to_oklch(); + let mut no_capture_bg_color = bg_color; + let mut disabled_bg_color = bg_color; + if bg_color.l > 0.5 { + bg_color.l -= 0.05; + disabled_bg_color.l -= 0.1; + no_capture_bg_color.l -= 0.075; + } else { + bg_color.l += 0.05; + disabled_bg_color.l += 0.1; + no_capture_bg_color.l += 0.075; + } + let fg_color_base = style.widgets.noninteractive.text_color().to_oklab(); + let fg_color_1 = fg_color_base * 1.0 / 3.0 + bg_color.to_oklab() * 2.0 / 3.0; + let fg_color_2 = fg_color_base * 2.0 / 3.0 + bg_color.to_oklab() * 1.0 / 3.0; + let painter = ui.painter_at(clip_rect); + painter.rect( + Rect::EVERYTHING, + 0.0, + disabled_bg_color, + Stroke::NONE, + StrokeKind::Inside, + ); + { + let x_min = 0.0; + let y_min = 0.0; + let x_max = MAX_EXTENTS as f32; + let y_max = MAX_EXTENTS as f32; + let good = Rect { + min: clip_rect.min + vec2(x_min, y_min) * scale - *origin, + max: clip_rect.min + vec2(x_max, y_max) * scale - *origin, + }; + painter.rect(good, 0.0, bg_color, Stroke::NONE, StrokeKind::Inside); + } + painter.hline( + clip_rect.left()..=clip_rect.right(), + clip_rect.min.y - origin.y, + (1.0, fg_color_1), + ); + painter.vline( + clip_rect.min.x - origin.x, + clip_rect.top()..=clip_rect.bottom(), + (1.0, fg_color_1), + ); + if self.settings.show_guide_lines { + for head in &heads { + let rect = head.rect; + painter.hline( + clip_rect.left()..=clip_rect.right(), + rect.top(), + (1.0, fg_color_2), + ); + painter.hline( + clip_rect.left()..=clip_rect.right(), + rect.bottom(), + (1.0, fg_color_2), + ); + painter.vline( + rect.left(), + clip_rect.top()..=clip_rect.bottom(), + (1.0, fg_color_2), + ); + painter.vline( + rect.right(), + clip_rect.top()..=clip_rect.bottom(), + (1.0, fg_color_2), + ); + } + } + for head in &mut heads { + let rect = head.rect; + let mut color = fg_color_2; + if *head.focus == self.ui.focus { + let shape = Shadow { + offset: [0, 0], + blur: (512.0 * scale).sqrt() as u8, + spread: (255.0 * scale).sqrt() as u8, + color: Color32::from_black_alpha(200), + } + .as_shape(rect, 0.0); + painter.add(shape); + color = fg_color_base; + } + painter.hline(rect.left()..=rect.right() + 1.0, rect.top(), (1.0, color)); + painter.hline( + rect.left()..=rect.right() + 1.0, + rect.bottom(), + (1.0, color), + ); + painter.vline(rect.left(), rect.top()..=rect.bottom() + 1.0, (1.0, color)); + painter.vline(rect.right(), rect.top()..=rect.bottom() + 1.0, (1.0, color)); + let content_rect = Rect { + min: pos2(rect.min.x + 1.0, rect.min.y + 1.0), + max: pos2(rect.max.x, rect.max.y), + }; + let painter = painter.with_clip_rect(content_rect); + painter.rect( + content_rect, + 0.0, + no_capture_bg_color, + Stroke::NONE, + StrokeKind::Inside, + ); + let galley = + painter.layout_no_wrap(head.m.name.to_string(), FontId::default(), Color32::WHITE); + let rect = Rect::from_min_size(content_rect.min, galley.rect.size() + vec2(2.0, 2.0)); + painter.rect(rect, 0.0, Color32::BLUE, Stroke::NONE, StrokeKind::Inside); + painter.galley(rect.min + vec2(1.0, 1.0), galley, Color32::WHITE); + } + ui.allocate_space(ui.available_size()); + macro_rules! interacted { + () => {{ + self.ui.zoom_to_fit = false; + }}; + } + let response = ui.allocate_rect(clip_rect, Sense::all()); + if response.has_focus() { + let mut dx = 0; + let mut dy = 0; + ui.ctx().input(|i| { + if i.key_pressed(Key::ArrowUp) { + dy -= 1; + } + if i.key_pressed(Key::ArrowDown) { + dy += 1; + } + if i.key_pressed(Key::ArrowLeft) { + dx -= 1; + } + if i.key_pressed(Key::ArrowRight) { + dx += 1; + } + }); + if dx != 0 || dy != 0 { + interacted!(); + for head in &mut heads { + if *head.focus == self.ui.focus { + let x = (head.x1 + dx).clamp(0, MAX_EXTENTS); + let y = (head.y1 + dy).clamp(0, MAX_EXTENTS); + let pos = (x, y); + if effective!(&*head.m, head.changed_state).position != pos { + modify!(&*head.m, head.changed_state).position = pos; + ui.ctx().request_repaint(); + } + } + } + } + } + if let Some(pos) = response.hover_pos() { + let scroll = ui.ctx().input(|i| i.smooth_scroll_delta); + let mut new = scale; + if scroll.y != 0.0 { + interacted!(); + } + if scroll.y < 0.0 { + new /= 1.0 - scroll.y / 1000.0; + } else { + new *= 1.0 + scroll.y / 1000.0; + } + new = new.max(0.01); + if new != scale { + self.ui.scale = new; + ui.ctx().request_repaint(); + let relative_pos = pos - clip_rect.min; + let real_pos = (relative_pos + *origin) / scale; + *origin = real_pos * new - relative_pos; + } + } + if ui + .ctx() + .input(|i| i.pointer.button_pressed(PointerButton::Primary)) + { + self.ui.focus += 1; + if let Some(pos) = response.hover_pos() { + interacted!(); + response.request_focus(); + for head in heads.iter_mut().rev() { + if head.rect.contains(pos) { + *head.z = self.ui.next_z; + self.ui.next_z += 1; + *head.focus = self.ui.focus; + ui.ctx().request_repaint(); + break; + } + } + } + } + if response.clicked_elsewhere() { + self.ui.focus += 1; + } + if response.drag_started_by(PointerButton::Middle) + || response.drag_started_by(PointerButton::Secondary) + { + interacted!(); + self.ui.origin_drag = Some(self.ui.origin); + } else if response.drag_started_by(PointerButton::Primary) + && let Some(pos) = response.hover_pos() + { + interacted!(); + for head in heads.iter_mut().rev() { + if head.rect.contains(pos) { + *head.drag_pos = Some((head.x1 as f32, head.y1 as f32)); + break; + } + } + } + let drag_delta = response.drag_delta(); + if drag_delta.x != 0.0 || drag_delta.y != 0.0 { + if let Some(origin_drag) = &mut self.ui.origin_drag { + *origin_drag -= drag_delta; + self.ui.origin = *origin_drag; + ui.ctx().request_repaint(); + } + let snap = self.settings.snap_to_neighbor ^ ui.ctx().input(|i| i.modifiers.shift); + let mut head_positions = vec![]; + struct HeadPosition { + name: HeadName, + x1: i32, + y1: i32, + x2: i32, + y2: i32, + } + if snap { + for head in &heads { + let PreparedHead { + name, + x1, + y1, + x2, + y2, + .. + } = *head; + head_positions.push(HeadPosition { + name, + x1, + y1, + x2, + y2, + }); + } + } + for head in &mut heads { + if let Some((mut x, mut y)) = *head.drag_pos { + x += drag_delta.x / scale; + y += drag_delta.y / scale; + let mut x_int = if x < 0.0 { x.ceil() } else { x.floor() } as i32; + let mut y_int = if y < 0.0 { y.ceil() } else { y.floor() } as i32; + if snap { + for other in &head_positions { + if head.name == other.name { + continue; + } + macro_rules! snap { + ($int:ident, $one:ident, $two:ident) => { + if $int.abs() as f32 * scale <= 10.0 { + $int = 0; + } else if ($int - other.$one).abs() as f32 * scale <= 10.0 { + $int = other.$one; + } else if ($int - other.$two).abs() as f32 * scale <= 10.0 { + $int = other.$two; + } else if ($int + head.$two - head.$one - other.$one).abs() + as f32 + * scale + <= 10.0 + { + $int = other.$one + head.$one - head.$two; + } else if ($int + head.$two - head.$one - other.$two).abs() + as f32 + * scale + <= 10.0 + { + $int = other.$two + head.$one - head.$two; + } + }; + } + snap!(x_int, x1, x2); + snap!(y_int, y1, y2); + } + } + x_int = x_int.clamp(0, MAX_EXTENTS); + y_int = y_int.clamp(0, MAX_EXTENTS); + let pos = (x_int, y_int); + if effective!(&*head.m, head.changed_state).position != pos { + modify!(&*head.m, head.changed_state).position = pos; + ui.ctx().request_repaint(); + } + *head.drag_pos = Some((x, y)); + } + } + } + if response.drag_stopped() { + self.ui.origin_drag = None; + for head in heads.iter_mut().rev() { + *head.drag_pos = None; + } + } + ui.ctx().memory_mut(|mem| { + mem.set_focus_lock_filter( + response.id, + EventFilter { + tab: false, + horizontal_arrows: true, + vertical_arrows: true, + escape: false, + }, + ) + }); + } + + fn prepare_transaction(&self) -> Result { + let mut tran = ConnectorTransaction::new(&self.state); + for head in self.heads.values() { + let Some(desired) = &head.changed_state else { + continue; + }; + let Some(connector) = self.state.connectors.get(&head.id) else { + return Err(HeadTransactionError::HeadRemoved(head.id)); + }; + if head.live_state.borrow().monitor_info != desired.monitor_info { + return Err(HeadTransactionError::MonitorChanged(head.id)); + } + let old = connector.state.borrow().clone(); + let mut new = old.clone(); + new.enabled = desired.connector_enabled; + new.mode = desired.mode; + new.non_desktop_override = desired.override_non_desktop; + new.format = desired.format; + new.color_space = desired.color_space; + new.eotf = desired.eotf; + if old == new { + continue; + } + tran.add(&connector.connector, new)?; + } + Ok(tran.prepare()?) + } + + fn commit_transaction(&self) -> Result<(), HeadTransactionError> { + self.prepare_transaction()?.apply()?.commit(); + for head in self.heads.values() { + let Some(desired) = &head.changed_state else { + continue; + }; + if let Some(output) = self.state.outputs.get(&head.id) + && let Some(node) = &output.node + { + node.set_position(desired.position.0, desired.position.1); + node.set_preferred_scale(desired.scale); + node.update_transform(desired.transform); + node.set_vrr_mode(&desired.vrr_mode); + node.set_tearing_mode(&desired.tearing_mode); + node.set_brightness(desired.brightness); + node.set_blend_space(desired.blend_space); + node.set_use_native_gamut(desired.use_native_gamut); + node.schedule + .set_cursor_hz(&self.state, desired.vrr_cursor_hz.unwrap_or(f64::INFINITY)); + } else if let Some(mi) = &desired.monitor_info { + let pos = &self.state.persistent_output_states; + let pos = pos.lock().entry(mi.output_id.clone()).or_default().clone(); + pos.pos.set(desired.position); + pos.scale.set(desired.scale); + pos.transform.set(desired.transform); + pos.vrr_mode.set(desired.vrr_mode); + pos.tearing_mode.set(desired.tearing_mode); + pos.brightness.set(desired.brightness); + pos.blend_space.set(desired.blend_space); + pos.use_native_gamut.set(desired.use_native_gamut); + pos.vrr_cursor_hz.set(desired.vrr_cursor_hz); + } + } + Ok(()) + } + + fn test_transaction(&self) -> Result<(), HeadTransactionError> { + self.prepare_transaction()?; + Ok(()) + } + + fn reset(&mut self) { + self.in_transaction.set(false); + let mut to_remove = vec![]; + for head in self.heads.values_mut() { + if self.state.connectors.contains(&head.id) { + head.changed_state = None; + } else { + to_remove.push(head.name); + } + } + for name in to_remove { + self.heads.remove(&name); + } + } + + fn add_new_heads(&mut self) { + for connector in self.state.connectors.lock().values() { + let mgrs = &connector.head_managers; + self.heads.entry(mgrs.name).or_insert_with(|| CompleteHead { + id: connector.id, + name: mgrs.name, + live_state: mgrs.state(), + changed_state: None, + z: 0, + focus: 0, + drag_pos: None, + }); + } + } +} + +fn show_connector(state: &State, settings: &Settings, head: &mut CompleteHead, ui: &mut Ui) { + let m = &*head.live_state.borrow(); + let t = &mut head.changed_state; + if t.is_none() { + if !m.connector_enabled && !settings.show_disabled { + return; + } + if !m.connected && !settings.show_disconnected { + return; + } + } + let mut layout_job = LayoutJob::default(); + layout_job.append( + "Connector", + 0.0, + TextFormat { + color: ui.style().visuals.widgets.inactive.text_color(), + ..Default::default() + }, + ); + layout_job.append( + &m.name, + 10.0, + TextFormat { + color: ui.style().visuals.widgets.active.text_color(), + ..Default::default() + }, + ); + let mut name = String::new(); + if let Some(v) = &m.monitor_info { + name.push_str(&v.output_id.manufacturer); + name.push_str(" - "); + name.push_str(&v.output_id.model); + } + layout_job.append( + &name, + 10.0, + TextFormat { + color: ui.style().visuals.widgets.inactive.text_color(), + ..Default::default() + }, + ); + ui.collapsing(layout_job, |ui| { + grid(ui, ("settings", head.name), |ui| { + let mut diff = false; + show_serial_number(ui, m); + diff |= show_enablement(ui, m, t); + diff |= show_position(ui, m, t); + diff |= show_scale(ui, m, t); + diff |= show_mode(ui, m, t); + diff |= show_size(ui, m, t); + diff |= show_transform(ui, m, t); + diff |= show_brightness(ui, m, t); + diff |= show_color_space(ui, m, t); + diff |= show_eotf(ui, m, t); + diff |= show_format(ui, m, t); + diff |= show_tearing(ui, m, t); + diff |= show_vrr(ui, m, t); + diff |= show_non_desktop(ui, m, t); + diff |= show_blend_space(ui, m, t); + diff |= show_use_native_gamut(ui, m, t); + show_native_gamut(ui, m); + diff |= show_cursor_hz(ui, m, t); + show_flip_margin(state, ui, m, t, head.id); + if diff { + let ui = &mut *ui.row(); + ui.label(""); + ui.label(""); + ui.label("^ current"); + } + }); + }); +} + +fn show_serial_number(ui: &mut Ui, m: &HeadState) { + if let Some(info) = &m.monitor_info { + let ui = &mut *ui.row(); + grid_label(ui, "Serial Number"); + ui.label(&info.output_id.serial_number); + } +} + +fn show_enablement(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + let ui = &mut *ui.row(); + grid_label(ui, "Enabled"); + let mut v = effective!(m, t).connector_enabled; + let changed = Checkbox::without_text(&mut v).ui(ui).changed(); + if changed { + let t = modify!(m, t); + t.connector_enabled = v; + t.update_in_compositor_space(m.wl_output); + } + let diff = v != m.connector_enabled; + if diff { + ui.label(match m.connector_enabled { + true => "enabled", + false => "disabled", + }); + } + diff +} + +fn show_position(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let ui = &mut *ui.row(); + grid_label(ui, "Position"); + let (mut x, mut y) = effective!(m, t).position; + ui.horizontal(|ui| { + let value = |ui: &mut Ui, v, min, max| { + let res = DragValue::new(v).range(min..=max).speed(1.0).ui(ui); + res.changed() + }; + let mut changed = false; + changed |= value(ui, &mut x, 0, MAX_EXTENTS); + ui.label("x"); + changed |= value(ui, &mut y, 0, MAX_EXTENTS); + if changed { + modify!(m, t).position = (x, y); + } + }); + let diff = m.position != (x, y); + if diff { + ui.label(format!("{} x {}", m.position.0, m.position.1)); + } + diff +} + +fn show_scale(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let ui = &mut *ui.row(); + grid_label(ui, "Scale"); + let mut v = effective!(m, t).scale; + let old = v; + ui.horizontal(|ui| { + let mut s = v.to_f64(); + let res = DragValue::new(&mut s) + .range(MIN_SCALE.to_f64()..=MAX_SCALE.to_f64()) + .speed(1.0 / SCALE_BASEF) + .fixed_decimals(5) + .ui(ui); + if res.changed() { + v = Scale::from_f64(s); + } + if ui.button(ICON_REMOVE).clicked() { + v = Scale::from_wl(v.to_wl().saturating_sub(SCALE_BASE)).clamp(MIN_SCALE, MAX_SCALE); + } + if ui.button(ICON_ADD).clicked() { + v = Scale::from_wl(v.to_wl().saturating_add(SCALE_BASE)).clamp(MIN_SCALE, MAX_SCALE); + } + }); + if old != v { + let t = modify!(m, t); + t.scale = v; + t.update_size(); + } + let diff = m.scale != v; + if diff { + ui.label(format!("{}", m.scale.to_f64())); + } + diff +} + +fn show_mode(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let ui = &mut *ui.row(); + let mut mode = effective!(m, t).mode; + let old = mode; + grid_label(ui, "Mode"); + let mode_text = |mode: Mode| { + format!( + "{}x{}@{}", + mode.width, + mode.height, + mode.refresh_rate_millihz as f64 / 1000.0 + ) + }; + if let Some(monitor_info) = &m.monitor_info + && monitor_info.modes.len() > 1 + { + ComboBox::from_id_salt("modes") + .selected_text(mode_text(mode)) + .show_ui(ui, |ui| { + for v in &monitor_info.modes { + ui.selectable_value(&mut mode, *v, mode_text(*v)); + } + }); + } else { + ui.label(mode_text(mode)); + } + if old != mode { + let t = modify!(m, t); + t.mode = mode; + t.update_size(); + } + let mut diff = false; + if m.mode != mode { + diff = true; + ui.label(mode_text(m.mode)); + } + diff +} + +fn show_size(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if let Some(info) = &m.monitor_info { + let ui = &mut *ui.row(); + grid_label(ui, "Physical Size (mm)"); + ui.label(format!("{} x {}", info.width_mm, info.height_mm)); + } + if !effective!(m, t).in_compositor_space { + return false; + } + let ui = &mut *ui.row(); + grid_label(ui, "Size"); + let (w, h) = effective!(m, t).size; + ui.label(format!("{w} x {h}")); + let diff = m.size != (w, h); + if diff { + ui.label(format!("{} x {}", m.size.0, m.size.1)); + } + diff +} + +fn show_transform(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let ui = &mut *ui.row(); + grid_label(ui, "Transform"); + let mut v = effective!(m, t).transform; + let mut changed = false; + let transform_name = |t: Transform| match t { + Transform::None => "none", + Transform::Rotate90 => "rotate-90", + Transform::Rotate180 => "rotate-180", + Transform::Rotate270 => "rotate-270", + Transform::Flip => "flip", + Transform::FlipRotate90 => "flip-rotate-90", + Transform::FlipRotate180 => "flip-rotate-180", + Transform::FlipRotate270 => "flip-rotate-270", + }; + ComboBox::from_id_salt("transform") + .selected_text(transform_name(v)) + .show_ui(ui, |ui| { + let transforms = [ + Transform::None, + Transform::Rotate90, + Transform::Rotate180, + Transform::Rotate270, + Transform::Flip, + Transform::FlipRotate90, + Transform::FlipRotate180, + Transform::FlipRotate270, + ]; + for s in transforms { + changed |= ui.selectable_value(&mut v, s, transform_name(s)).changed(); + } + }); + if changed { + let t = modify!(m, t); + t.transform = v; + t.update_size(); + } + let diff = m.transform != v; + if diff { + ui.label(transform_name(m.transform)); + } + diff +} + +fn show_brightness(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let old_custom_brightness = effective!(m, t).brightness.is_some(); + let mut custom_brightness = old_custom_brightness; + let mut changed = false; + grid_label(ui, "Custom Brightness"); + Checkbox::without_text(&mut custom_brightness).ui(ui); + changed |= old_custom_brightness != custom_brightness; + let diff1 = m.brightness.is_some() != custom_brightness; + if diff1 { + ui.label(match m.brightness.is_some() { + true => "enabled", + false => "disabled", + }); + } + ui.end_row(); + + if !custom_brightness { + if changed { + modify!(m, t).brightness = None; + } + return diff1; + } + + grid_label(ui, "Brightness"); + ui.vertical(|ui| { + let effective = effective!(m, t); + let default_brightness = match effective.eotf { + BackendEotfs::Default => effective + .monitor_info + .as_ref() + .and_then(|m| m.luminance.as_ref()) + .map(|l| l.max) + .unwrap_or(Luminance::SRGB.white.0), + BackendEotfs::Pq => Luminance::ST2084_PQ.white.0, + }; + let mut brightness = effective.brightness.unwrap_or(default_brightness); + changed |= DragValue::new(&mut brightness) + .range(0.0..=1000.0) + .ui(ui) + .changed(); + ui.label(format!("reference: {default_brightness})")); + if changed { + modify!(m, t).brightness = Some(brightness); + } + }); + let mut diff2 = false; + if let Some(t) = t + && m.brightness != t.brightness + { + diff2 = true; + ui.label(format!( + "{}", + fmt::from_fn(|f| match m.brightness { + None => f.write_str("disabled"), + Some(v) => write!(f, "{}", v), + }) + )); + } + ui.end_row(); + diff1 || diff2 +} + +fn show_color_space(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let ui = &mut *ui.row(); + grid_label(ui, "Colorimetry"); + let mut v = effective!(m, t).color_space; + ui.horizontal(|ui| { + if let Some(monitor_info) = &effective!(m, t).monitor_info { + if monitor_info.color_spaces.is_empty() { + ui.label(v.name()); + } else { + let mut changed = false; + ComboBox::from_id_salt("colorimetry") + .selected_text(v.name()) + .show_ui(ui, |ui| { + changed |= ui + .selectable_value( + &mut v, + BackendColorSpace::Default, + BackendColorSpace::Default.name(), + ) + .changed(); + for &s in &monitor_info.color_spaces { + changed |= ui.selectable_value(&mut v, s, s.name()).changed(); + } + }); + if changed { + modify!(m, t).color_space = v; + } + } + } + }); + let diff = m.color_space != v; + if diff { + ui.label(m.color_space.name()); + } + diff +} + +fn show_eotf(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let ui = &mut *ui.row(); + grid_label(ui, "EOTF"); + let mut v = effective!(m, t).eotf; + ui.horizontal(|ui| { + if let Some(monitor_info) = &effective!(m, t).monitor_info { + if monitor_info.eotfs.is_empty() { + ui.label(v.name()); + } else { + let mut changed = false; + ComboBox::from_id_salt("eotf") + .selected_text(v.name()) + .show_ui(ui, |ui| { + changed |= ui + .selectable_value( + &mut v, + BackendEotfs::Default, + BackendEotfs::Default.name(), + ) + .changed(); + for &s in &monitor_info.eotfs { + changed |= ui.selectable_value(&mut v, s, s.name()).changed(); + } + }); + if changed { + modify!(m, t).eotf = v; + } + } + } + }); + let diff = m.eotf != v; + if diff { + ui.label(m.eotf.name()); + } + diff +} + +fn show_format(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let ui = &mut *ui.row(); + grid_label(ui, "Format"); + let mut v = effective!(m, t).format; + ui.horizontal(|ui| { + if m.supported_formats.len() < 2 { + ui.label(v.name); + } else { + let mut changed = false; + ComboBox::from_id_salt("format") + .selected_text(v.name) + .show_ui(ui, |ui| { + for &s in &*m.supported_formats { + changed |= ui.selectable_value(&mut v, s, s.name).changed(); + } + }); + if changed { + modify!(m, t).format = v; + } + } + }); + let diff = m.format != v; + if diff { + ui.label(m.format.name); + } + diff +} + +fn show_tearing(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let render_settings = |ui: &mut Ui, old: TearingMode| { + #[derive(Copy, Clone, PartialEq, Linearize)] + enum Mode { + Never, + Always, + Fullscreen, + } + fn name(mode: Mode) -> &'static str { + match mode { + Mode::Never => "Never", + Mode::Always => "Always", + Mode::Fullscreen => "Fullscreen", + } + } + let mut mode = match old { + TearingMode::Never => Mode::Never, + TearingMode::Always => Mode::Always, + TearingMode::Fullscreen { .. } => Mode::Fullscreen, + }; + let old_mode = mode; + let mut surface = None; + ui.vertical(|ui| { + ComboBox::from_id_salt("tearing mode") + .selected_text(name(mode)) + .show_ui(ui, |ui| { + for s in Mode::variants() { + ui.selectable_value(&mut mode, s, name(s)); + } + }); + if mode == Mode::Fullscreen { + if old_mode != mode { + surface = Some(Default::default()); + } + if let TearingMode::Fullscreen { surface: s } = old { + surface = s; + } + let mut limit_windows = surface.is_some(); + ui.checkbox(&mut limit_windows, "Limit Windows"); + if !limit_windows { + surface = None; + } else { + ui.indent("limit windows", |ui| { + let surface = surface.get_or_insert_default(); + ui.checkbox(&mut surface.tearing_requested, "Requests Tearing"); + }); + } + } + }); + match mode { + Mode::Never => TearingMode::Never, + Mode::Always => TearingMode::Always, + Mode::Fullscreen => TearingMode::Fullscreen { surface }, + } + }; + let ui = &mut *ui.row(); + grid_label(ui, "Tearing"); + let old = effective!(m, t).tearing_mode; + let v = render_settings(ui, old); + if v != old { + modify!(m, t).tearing_mode = v; + } + let diff = v != m.tearing_mode; + if diff { + ui.add_enabled_ui(false, |ui| { + render_settings(ui, m.tearing_mode); + }); + } + diff +} + +fn show_vrr(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + if let Some(info) = &m.monitor_info + && !info.vrr_capable + { + return false; + } + { + let ui = &mut *ui.row(); + grid_label(ui, "VRR Active"); + ui.label(effective!(m, t).vrr.to_string()); + } + let render_settings = |ui: &mut Ui, old: VrrMode| { + #[derive(Copy, Clone, PartialEq, Linearize)] + enum Mode { + Never, + Always, + Fullscreen, + } + fn name(mode: Mode) -> &'static str { + match mode { + Mode::Never => "Never", + Mode::Always => "Always", + Mode::Fullscreen => "Fullscreen", + } + } + let mut mode = match old { + VrrMode::Never => Mode::Never, + VrrMode::Always => Mode::Always, + VrrMode::Fullscreen { .. } => Mode::Fullscreen, + }; + let mut surface = None; + ui.vertical(|ui| { + ComboBox::from_id_salt("vrr mode") + .selected_text(name(mode)) + .show_ui(ui, |ui| { + for s in Mode::variants() { + ui.selectable_value(&mut mode, s, name(s)); + } + }); + if mode == Mode::Fullscreen { + if let VrrMode::Fullscreen { surface: s } = old { + surface = s; + } + let mut limit_windows = surface.is_some(); + ui.checkbox(&mut limit_windows, "Limit Windows"); + if !limit_windows { + surface = None; + } else { + ui.indent("limit windows", |ui| { + let surface = surface.get_or_insert_default(); + let mut limit_content_type = surface.content_type.is_some(); + ui.checkbox(&mut limit_content_type, "Limit Content Types"); + if !limit_content_type { + surface.content_type = None; + } else { + ui.indent("limit content type", |ui| { + let limit = surface.content_type.get_or_insert_default(); + let fields = [ + ("Photos", &mut limit.photo), + ("Videos", &mut limit.video), + ("Games", &mut limit.game), + ]; + for (name, field) in fields { + ui.checkbox(field, name); + } + }); + } + }); + } + } + }); + match mode { + Mode::Never => VrrMode::Never, + Mode::Always => VrrMode::Always, + Mode::Fullscreen => VrrMode::Fullscreen { surface }, + } + }; + let ui = &mut *ui.row(); + grid_label(ui, "VRR"); + let old = effective!(m, t).vrr_mode; + let v = render_settings(ui, old); + if v != old { + modify!(m, t).vrr_mode = v; + } + let diff = v != m.vrr_mode; + if diff { + ui.add_enabled_ui(false, |ui| { + render_settings(ui, m.vrr_mode); + }); + } + diff +} + +fn show_non_desktop(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + { + let ui = &mut *ui.row(); + grid_label(ui, "Non-desktop"); + if m.inherent_non_desktop { + ui.label("Yes"); + } else { + ui.label("No"); + } + } + + let ui = &mut *ui.row(); + grid_label(ui, "Override"); + let mut v = effective!(m, t).override_non_desktop; + let mut changed = false; + let name = |v: Option| match v { + None => "None", + Some(false) => "Desktop", + Some(true) => "Non-Desktop", + }; + ComboBox::from_id_salt("non-desktop-override") + .selected_text(name(v)) + .show_ui(ui, |ui| { + for s in [None, Some(false), Some(true)] { + changed |= ui.selectable_value(&mut v, s, name(s)).changed(); + } + }); + if changed { + let t = modify!(m, t); + t.override_non_desktop = v; + t.update_in_compositor_space(m.wl_output); + } + let diff = v != m.override_non_desktop; + if diff { + ui.label(name(m.override_non_desktop)); + } + diff +} + +fn show_blend_space(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let ui = &mut *ui.row(); + grid_label(ui, "Blend Space"); + let mut v = effective!(m, t).blend_space; + ui.horizontal(|ui| { + let mut changed = false; + ComboBox::from_id_salt("blend-space") + .selected_text(v.name()) + .show_ui(ui, |ui| { + for s in BlendSpace::variants() { + changed |= ui.selectable_value(&mut v, s, s.name()).changed(); + } + }); + if changed { + modify!(m, t).blend_space = v; + } + }); + let diff = m.blend_space != v; + if diff { + ui.label(m.blend_space.name()); + } + diff +} + +fn show_use_native_gamut(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let ui = &mut *ui.row(); + grid_label(ui, "Use Native Gamut"); + let mut use_native_gamut = effective!(m, t).use_native_gamut; + if Checkbox::without_text(&mut use_native_gamut) + .ui(ui) + .changed() + { + modify!(m, t).use_native_gamut = use_native_gamut; + } + let diff = m.use_native_gamut != use_native_gamut; + if diff { + let mut old = m.use_native_gamut; + ui.add_enabled(false, Checkbox::without_text(&mut old)); + } + diff +} + +fn show_native_gamut(ui: &mut Ui, m: &HeadState) { + let Some(info) = &m.monitor_info else { + return; + }; + let p = info.primaries; + let ui = &mut *ui.row(); + grid_label(ui, "Native Gamut"); + Grid::new("native gamut").show(ui, |ui| { + let fields = [ + ("red:", p.r), + ("green:", p.g), + ("blue:", p.b), + ("white:", p.wp), + ]; + for (name, field) in fields { + let ui = &mut *ui.row(); + ui.label(name); + ui.label(format!("{:.6}", field.0)); + ui.label(format!("{:.6}", field.1)); + } + }); +} + +fn show_cursor_hz(ui: &mut Ui, m: &HeadState, t: &mut Option) -> bool { + if !effective!(m, t).in_compositor_space { + return false; + } + let old_cursor_hz = effective!(m, t).vrr_cursor_hz.is_some(); + let mut custom_cursor_hz = old_cursor_hz; + let mut changed = false; + grid_label(ui, "Limit Cursor HZ"); + Checkbox::without_text(&mut custom_cursor_hz).ui(ui); + changed |= old_cursor_hz != custom_cursor_hz; + let diff1 = m.vrr_cursor_hz.is_some() != custom_cursor_hz; + if diff1 { + ui.label(match m.vrr_cursor_hz.is_some() { + true => "enabled", + false => "disabled", + }); + } + ui.end_row(); + + if !custom_cursor_hz { + if changed { + modify!(m, t).vrr_cursor_hz = None; + } + return diff1; + } + + grid_label(ui, "Cursor HZ"); + let mut cursor_hz = effective!(m, t).vrr_cursor_hz.unwrap_or(60.0); + changed |= DragValue::new(&mut cursor_hz) + .range(0.0..=500.0) + .ui(ui) + .changed(); + if changed { + modify!(m, t).vrr_cursor_hz = Some(cursor_hz); + } + let mut diff2 = false; + if let Some(t) = t + && m.vrr_cursor_hz != t.vrr_cursor_hz + { + diff2 = true; + ui.label(format!( + "{}", + fmt::from_fn(|f| match m.vrr_cursor_hz { + None => f.write_str("disabled"), + Some(v) => write!(f, "{}", v), + }) + )); + } + ui.end_row(); + diff1 || diff2 +} + +fn show_flip_margin( + state: &State, + ui: &mut Ui, + m: &HeadState, + t: &mut Option, + connector_id: ConnectorId, +) { + if !effective!(m, t).in_compositor_space { + return; + } + let Some(node) = state.root.outputs.get(&connector_id) else { + return; + }; + let Some(margin) = node.flip_margin_ns.get() else { + return; + }; + label( + ui, + "Flip Margin (ms)", + format!("{}", margin as f64 / 1_000_000.0), + ); +} diff --git a/src/control_center/cc_sidebar.rs b/src/control_center/cc_sidebar.rs index a3b5296e..d1a74ea2 100644 --- a/src/control_center/cc_sidebar.rs +++ b/src/control_center/cc_sidebar.rs @@ -12,6 +12,7 @@ enum PaneName { Idle, ColorManagement, Xwayland, + Outputs, } impl PaneName { @@ -21,6 +22,7 @@ impl PaneName { PaneName::Idle => "Idle", PaneName::ColorManagement => "Color Management", PaneName::Xwayland => "Xwayland", + PaneName::Outputs => "Outputs", } } } @@ -55,6 +57,9 @@ impl ControlCenterInner { PaneName::Xwayland => { PaneType::Xwayland(self.create_xwayland_pane()) } + PaneName::Outputs => { + PaneType::Outputs(Box::new(self.create_outputs_pane())) + } }; self.open(tree, ty); ui.ctx().request_repaint(); diff --git a/src/egui_adapter/egui_oklch.rs b/src/egui_adapter/egui_oklch.rs index 6710ca0a..dedbb4d3 100644 --- a/src/egui_adapter/egui_oklch.rs +++ b/src/egui_adapter/egui_oklch.rs @@ -6,7 +6,6 @@ use { egui::{Color32, Rgba}, }; -#[expect(dead_code)] pub trait Color32Ext { fn to_oklab(self) -> Oklab; fn to_oklch(self) -> Oklch; diff --git a/src/egui_adapter/egui_platform.rs b/src/egui_adapter/egui_platform.rs index 1d1afd77..e51b27c4 100644 --- a/src/egui_adapter/egui_platform.rs +++ b/src/egui_adapter/egui_platform.rs @@ -110,7 +110,6 @@ pub enum EggError { } pub mod icons { - #[expect(dead_code)] pub const ICON_ADD: &str = "\u{e145}"; pub const ICON_CLOSE: &str = "\u{e5cd}"; pub const ICON_DRAG_INDICATOR: &str = "\u{e945}"; @@ -119,7 +118,6 @@ pub mod icons { pub const ICON_OPEN_IN_NEW: &str = "\u{e89e}"; #[expect(dead_code)] pub const ICON_PENDING: &str = "\u{ef64}"; - #[expect(dead_code)] pub const ICON_REMOVE: &str = "\u{e15b}"; } diff --git a/src/ifs/head_management.rs b/src/ifs/head_management.rs index 8b1bf6bc..46dd514b 100644 --- a/src/ifs/head_management.rs +++ b/src/ifs/head_management.rs @@ -105,14 +105,13 @@ pub struct ReadOnlyHeadState { } impl ReadOnlyHeadState { - #[expect(dead_code)] pub fn borrow(&self) -> Ref<'_, HeadState> { self.state.borrow() } } impl HeadState { - fn update_in_compositor_space(&mut self, wl_output: Option) { + pub fn update_in_compositor_space(&mut self, wl_output: Option) { self.in_compositor_space = false; self.wl_output = None; if !self.connector_enabled { @@ -131,7 +130,7 @@ impl HeadState { self.wl_output = wl_output; } - fn update_size(&mut self) { + pub fn update_size(&mut self) { self.size = OutputNode::calculate_extents_(self.mode, self.transform, self.scale, self.position) .size(); @@ -213,7 +212,7 @@ pub enum HeadCommonError { } pub struct HeadManagers { - name: HeadName, + pub name: HeadName, state: Rc>, managers: CopyHashMap<(ClientId, JayHeadManagerSessionV1Id), Rc>, } @@ -235,7 +234,6 @@ impl HeadManagers { } } - #[expect(dead_code)] pub fn state(&self) -> ReadOnlyHeadState { ReadOnlyHeadState { state: self.state.clone(), diff --git a/src/ifs/jay_randr.rs b/src/ifs/jay_randr.rs index 310b1716..604ef55b 100644 --- a/src/ifs/jay_randr.rs +++ b/src/ifs/jay_randr.rs @@ -456,7 +456,7 @@ impl JayRandrRequestHandler for JayRandr { let Some(c) = self.get_output_node(req.output) else { return Ok(()); }; - c.schedule.set_cursor_hz(req.hz); + c.schedule.set_cursor_hz(&self.state, req.hz); Ok(()) } diff --git a/src/output_schedule.rs b/src/output_schedule.rs index c9420c21..dec598e8 100644 --- a/src/output_schedule.rs +++ b/src/output_schedule.rs @@ -2,9 +2,10 @@ use { crate::{ async_engine::AsyncEngine, backend::HardwareCursor, + control_center::CCI_OUTPUTS, ifs::wl_output::PersistentOutputState, io_uring::{IoUring, IoUringError}, - state::ConnectorData, + state::{ConnectorData, State}, utils::{ asyncevent::AsyncEvent, cell_ext::CellExt, clonecell::CloneCell, errorfmt::ErrorFmt, numcell::NumCell, @@ -51,8 +52,7 @@ pub struct OutputSchedule { impl OutputSchedule { pub fn new( - ring: &Rc, - eng: &Rc, + state: &State, connector: &Rc, persistent: &Rc, ) -> Self { @@ -60,8 +60,8 @@ impl OutputSchedule { changed: Default::default(), run: Default::default(), connector: connector.clone(), - ring: ring.clone(), - eng: eng.clone(), + ring: state.ring.clone(), + eng: state.eng.clone(), vrr_enabled: Default::default(), hardware_cursor_change: Cell::new(Change::None), software_cursor_change: Cell::new(Change::None), @@ -72,7 +72,7 @@ impl OutputSchedule { iteration: Default::default(), }; if let Some(hz) = persistent.vrr_cursor_hz.get() { - slf.set_cursor_hz(hz); + slf.set_cursor_hz(state, hz); } slf } @@ -118,7 +118,7 @@ impl OutputSchedule { self.trigger(); } - pub fn set_cursor_hz(&self, hz: f64) { + pub fn set_cursor_hz(&self, state: &State, hz: f64) { let (hz, delta) = match map_cursor_hz(hz) { None => { log::warn!("Ignoring cursor frequency {hz}"); @@ -128,6 +128,7 @@ impl OutputSchedule { }; self.persistent.vrr_cursor_hz.set(hz); self.connector.head_managers.handle_cursor_hz_change(hz); + state.trigger_cci(CCI_OUTPUTS); self.cursor_delta_nsec.set(delta); self.trigger(); } diff --git a/src/state.rs b/src/state.rs index 97361a3c..8de91570 100644 --- a/src/state.rs +++ b/src/state.rs @@ -17,7 +17,8 @@ use { compositor::{LIBEI_SOCKET, LogLevel}, config::ConfigProxy, control_center::{ - CCI_COLOR_MANAGEMENT, CCI_COMPOSITOR, CCI_IDLE, CCI_XWAYLAND, ControlCenters, + CCI_COLOR_MANAGEMENT, CCI_COMPOSITOR, CCI_IDLE, CCI_OUTPUTS, CCI_XWAYLAND, + ControlCenters, }, copy_device::CopyDeviceRegistry, cpu_worker::CpuWorker, @@ -493,30 +494,39 @@ impl ConnectorData { return; } *self.state.borrow_mut() = s.clone(); - if old.enabled != s.enabled { + macro_rules! b { + ($expr:expr) => {{ + let e = $expr; + if e { + state.trigger_cci(CCI_OUTPUTS); + } + e + }}; + } + if b!(old.enabled != s.enabled) { self.head_managers.handle_enabled_change(s.enabled); } - if old.active != s.active { + if b!(old.active != s.active) { self.head_managers.handle_active_change(s.active); } - if old.non_desktop_override != s.non_desktop_override { + if b!(old.non_desktop_override != s.non_desktop_override) { self.head_managers .handle_non_desktop_override_changed(s.non_desktop_override); } - if old.vrr != s.vrr { + if b!(old.vrr != s.vrr) { self.head_managers.handle_vrr_change(s.vrr); } - if old.tearing != s.tearing { + if b!(old.tearing != s.tearing) { self.head_managers.handle_tearing_enabled_change(s.tearing); } - if old.format != s.format { + if b!(old.format != s.format) { self.head_managers.handle_format_change(s.format); } - if (old.color_space, old.eotf) != (s.color_space, s.eotf) { + if b!((old.color_space, old.eotf) != (s.color_space, s.eotf)) { self.head_managers .handle_colors_change(s.color_space, s.eotf); } - if old.mode != s.mode { + if b!(old.mode != s.mode) { self.head_managers.handle_mode_change(s.mode); for head in self.wlr_output_heads.lock().values() { head.handle_mode_change(s.mode); diff --git a/src/tasks/connector.rs b/src/tasks/connector.rs index aee14494..82bfc4ce 100644 --- a/src/tasks/connector.rs +++ b/src/tasks/connector.rs @@ -4,6 +4,7 @@ use { BackendConnectorState, BackendConnectorStateSerial, Connector, ConnectorEvent, ConnectorId, MonitorInfo, }, + control_center::CCI_OUTPUTS, format::XRGB8888, globals::GlobalName, ifs::{ @@ -108,6 +109,7 @@ pub fn handle(state: &Rc, connector: &Rc) { for mgr in state.head_managers.lock().values() { mgr.announce(&data); } + state.trigger_cci(CCI_OUTPUTS); if state.connectors.set(id, data).is_some() { panic!("Connector id has been reused"); } @@ -147,6 +149,7 @@ impl ConnectorHandler { self.data.handler.set(None); self.state.connectors.remove(&self.id); self.data.head_managers.handle_removed(); + self.state.trigger_cci(CCI_OUTPUTS); } async fn handle_connected(&self, info: MonitorInfo) { @@ -162,6 +165,7 @@ impl ConnectorHandler { } self.data.connected.set(false); self.data.head_managers.handle_output_disconnected(); + self.state.trigger_cci(CCI_OUTPUTS); for head in self.data.wlr_output_heads.lock().drain_values() { head.handle_disconnected(); } @@ -213,12 +217,7 @@ impl ConnectorHandler { info.primaries, info.luminance, )); - let schedule = Rc::new(OutputSchedule::new( - &self.state.ring, - &self.state.eng, - &self.data, - &desired_state, - )); + let schedule = Rc::new(OutputSchedule::new(&self.state, &self.data, &desired_state)); let _schedule = self .state .eng @@ -341,6 +340,7 @@ impl ConnectorHandler { self.data .head_managers .handle_output_connected(&output_data); + self.state.trigger_cci(CCI_OUTPUTS); self.state.wlr_output_managers.announce_head(&output_data); 'outer: loop { while let Some(event) = self.data.connector.event() { @@ -353,6 +353,7 @@ impl ConnectorHandler { } ConnectorEvent::FormatsChanged(formats) => { self.data.head_managers.handle_formats_change(&formats); + self.state.trigger_cci(CCI_OUTPUTS); on.global.formats.set(formats); } ConnectorEvent::State(state) => { @@ -466,6 +467,7 @@ impl ConnectorHandler { self.data .head_managers .handle_output_connected(&output_data); + self.state.trigger_cci(CCI_OUTPUTS); self.state.wlr_output_managers.announce_head(&output_data); 'outer: loop { while let Some(event) = self.data.connector.event() { diff --git a/src/tree/output.rs b/src/tree/output.rs index bf047663..79b70dc2 100644 --- a/src/tree/output.rs +++ b/src/tree/output.rs @@ -6,6 +6,7 @@ use { }, client::ClientId, cmm::cmm_description::ColorDescription, + control_center::CCI_OUTPUTS, cursor::KnownCursor, fixed::Fixed, gfx_api::{AcquireSync, BufferResv, GfxTexture, ReleaseSync}, @@ -243,6 +244,7 @@ impl OutputNode { .connector .head_managers .handle_tearing_active_change(tearing); + self.state.trigger_cci(CCI_OUTPUTS); } } @@ -501,6 +503,7 @@ impl OutputNode { .connector .head_managers .handle_scale_change(scale); + self.state.trigger_cci(CCI_OUTPUTS); for head in self.global.connector.wlr_output_heads.lock().values() { head.handle_new_scale(scale); } @@ -873,6 +876,7 @@ impl OutputNode { .connector .head_managers .handle_transform_change(transform); + self.state.trigger_cci(CCI_OUTPUTS); for head in self.global.connector.wlr_output_heads.lock().values() { head.hande_transform_change(transform); } @@ -935,6 +939,7 @@ impl OutputNode { .connector .head_managers .handle_position_size_change(self); + self.state.trigger_cci(CCI_OUTPUTS); } pub fn update_state(self: &Rc, old: BackendConnectorState, state: BackendConnectorState) { @@ -989,6 +994,7 @@ impl OutputNode { .connector .head_managers .handle_brightness_change(brightness); + self.state.trigger_cci(CCI_OUTPUTS); } } @@ -1004,6 +1010,7 @@ impl OutputNode { .connector .head_managers .handle_use_native_gamut_change(use_native_gamut); + self.state.trigger_cci(CCI_OUTPUTS); } } @@ -1015,6 +1022,7 @@ impl OutputNode { .connector .head_managers .handle_blend_space_change(blend_space); + self.state.trigger_cci(CCI_OUTPUTS); } } fn find_stacked_at( @@ -1480,6 +1488,7 @@ impl OutputNode { .connector .head_managers .handle_vrr_mode_change(mode); + self.state.trigger_cci(CCI_OUTPUTS); for head in self.global.connector.wlr_output_heads.lock().values() { head.handle_vrr_mode_change(mode); } @@ -1494,6 +1503,7 @@ impl OutputNode { .connector .head_managers .handle_tearing_mode_change(mode); + self.state.trigger_cci(CCI_OUTPUTS); } } @@ -1543,6 +1553,7 @@ impl OutputNode { pub fn set_flip_margin(&self, margin_ns: u64) { self.flip_margin_ns.set(Some(margin_ns)); + self.state.trigger_cci(CCI_OUTPUTS); } }