git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit72b21efbe7573f5ab80ea304c39263a3905e83c7
parent4124d5a743
authorLucas Galante <[email protected]>
date2026-06-28 19:22
Implement per-output display scale configuration and menus support

 Makefile                     |  2 +
 scripts/cce-app-menu         | 92 +++++++++++++++++++++++++++++++++++++++++
 scripts/cce-desktop-menu     | 75 +++++++++++++++++++++++++++++++++
 src/server/config.rs         | 16 ++++++++
 src/server/cursor.rs         | 54 +++++++++++++++++++++++-
 src/server/output.rs         | 98 ++++++++++++++++++++++++--------------------
 src/server/output_manager.rs |  5 +++
 src/server/window_manager.rs | 23 +++++++++++
 8 files changed, 319 insertions(+), 46 deletions(-)

diff --git a/Makefile b/Makefile
index fbba089..0f59205 100644
--- a/Makefile
+++ b/Makefile
@@ -10,6 +10,8 @@ install: build
 	else \
 		echo "Error: cce binary not found"; exit 1; \
 	fi
+	install -m 755 scripts/cce-desktop-menu ~/.local/bin/cce-desktop-menu
+	install -m 755 scripts/cce-app-menu ~/.local/bin/cce-app-menu
 
 run:
 	cargo run --bin cce
diff --git a/scripts/cce-app-menu b/scripts/cce-app-menu
new file mode 100644
index 0000000..3112702
--- /dev/null
+++ b/scripts/cce-app-menu
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+# cce-app-menu — Context menu for CCE application windows
+
+CLEARCTL="/home/lsgalante/.local/bin/cce control"
+CLEAR_CLOUD="/home/lsgalante/.local/bin/clear-cloud"
+
+# Default to empty/none
+x_arg=""
+y_arg=""
+window_index=""
+app_id=""
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -x|--x-pos)
+            x_arg="-x $2"
+            shift 2
+            ;;
+        -y|--y-pos)
+            y_arg="-y $2"
+            shift 2
+            ;;
+        -i|--window-id)
+            window_index="$2"
+            shift 2
+            ;;
+        -a|--app-id)
+            app_id="$2"
+            shift 2
+            ;;
+        *)
+            shift
+            ;;
+    esac
+done
+
+if [[ -z "$window_index" || -z "$app_id" ]]; then
+    echo "Usage: cce-app-menu -i <window_index> -a <app_id> [-x <x>] [-y <y>]" >&2
+    exit 1
+fi
+
+# Define the JSON layout for the application context menu
+json_layout='{
+  "pages": [
+    {
+      "title": "'"${app_id}"' Window Options",
+      "justify": "left",
+      "widgets": [
+        { "type": "button", "text": "Maximize", "id": "maximize" },
+        { "type": "button", "text": "Tile (Grid)", "id": "tile_grid" },
+        { "type": "button", "text": "Float", "id": "float" },
+        { "type": "button", "text": "Fullscreen", "id": "fullscreen" },
+        { "type": "button", "text": "Minimize", "id": "minimize" },
+        { "type": "button", "text": "Close Window", "id": "close" }
+      ]
+    }
+  ]
+}'
+
+# Spawn clear-cloud with the json layout and coordinates
+selected=$(echo "$json_layout" | $CLEAR_CLOUD --json $x_arg $y_arg 2>/dev/null)
+
+[[ -z "$selected" ]] && exit 0
+
+# Parse button using python for reliability and robustness
+btn=$(echo "$selected" | python3 -c "import sys, json; print(json.load(sys.stdin).get('button', ''))")
+
+[[ -z "$btn" ]] && exit 0
+
+# First focus the window so the commands apply to it
+$CLEARCTL focus-window "$window_index"
+
+case "$btn" in
+    "maximize")
+        $CLEARCTL mode maximized "$app_id"
+        ;;
+    "tile_grid")
+        $CLEARCTL mode grid "$app_id"
+        ;;
+    "float")
+        $CLEARCTL mode floating "$app_id"
+        ;;
+    "fullscreen")
+        $CLEARCTL mode fullscreen "$app_id"
+        ;;
+    "minimize")
+        $CLEARCTL minimize
+        ;;
+    "close")
+        $CLEARCTL close
+        ;;
+esac
diff --git a/scripts/cce-desktop-menu b/scripts/cce-desktop-menu
new file mode 100644
index 0000000..e7732cf
--- /dev/null
+++ b/scripts/cce-desktop-menu
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# cce-desktop-menu — Context menu for CCE desktop background
+
+CLEARCTL="/home/lsgalante/.local/bin/cce control"
+CLEAR_CLOUD="/home/lsgalante/.local/bin/clear-cloud"
+
+# Default to empty coordinates if not provided
+x_arg=""
+y_arg=""
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -x|--x-pos)
+            x_arg="-x $2"
+            shift 2
+            ;;
+        -y|--y-pos)
+            y_arg="-y $2"
+            shift 2
+            ;;
+        *)
+            shift
+            ;;
+    esac
+done
+
+# Define the JSON layout for the desktop context menu
+json_layout='{
+  "pages": [
+    {
+      "title": "Desktop Context Menu",
+      "justify": "left",
+      "widgets": [
+        { "type": "button", "text": "Terminal", "id": "terminal" },
+        { "type": "button", "text": "Applications", "id": "apps" },
+        { "type": "button", "text": "System Settings", "id": "settings" },
+        { "type": "button", "text": "Expose Windows", "id": "expose" },
+        { "type": "button", "text": "Reload Config", "id": "reload" },
+        { "type": "button", "text": "Exit CCE", "id": "exit" }
+      ]
+    }
+  ]
+}'
+
+# Spawn clear-cloud with the json layout and coordinates
+selected=$(echo "$json_layout" | $CLEAR_CLOUD --json $x_arg $y_arg 2>/dev/null)
+
+[[ -z "$selected" ]] && exit 0
+
+# Parse button using python for reliability and robustness
+btn=$(echo "$selected" | python3 -c "import sys, json; print(json.load(sys.stdin).get('button', ''))")
+
+case "$btn" in
+    "terminal")
+        # Run terminal
+        foot &
+        ;;
+    "apps")
+        # Run applications search/list
+        $CLEAR_CLOUD --mode apps &
+        ;;
+    "settings")
+        # Run settings interface
+        /home/lsgalante/.local/bin/cce-system-settings &
+        ;;
+    "expose")
+        $CLEARCTL expose
+        ;;
+    "reload")
+        $CLEARCTL reload
+        ;;
+    "exit")
+        $CLEARCTL exit
+        ;;
+esac
diff --git a/src/server/config.rs b/src/server/config.rs
index 082947a..c45ad6d 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -289,6 +289,8 @@ pub struct Config {
     #[serde(default)]
     pub output: Option<OutputConfig>,
     #[serde(default)]
+    pub display: HashMap<String, f64>,
+    #[serde(default)]
     pub device: Vec<InputDeviceConfigRule>,
     #[serde(default)]
     pub input: Option<InputConfig>,
@@ -712,6 +714,7 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     };
 
     state.output_scale = config.output.as_ref().map(|o| o.scale as f32).unwrap_or(1.0f32);
+    state.display = config.display.clone();
 
     state.layout.gap = config.layout.gap as i32;
     state.layout.gap_top = config.layout.gap_top as i32;
@@ -989,4 +992,17 @@ mod tests {
             assert_eq!(server.wm.layout.background_b, b * 0x01010101);
         }
     }
+
+    #[test]
+    fn test_display_scale_parsing() {
+        let content = r#"{
+            "display": {
+                "scale_eDP-1": 2.0,
+                "scale_DP-1": 1.5
+            }
+        }"#;
+        let config: Config = serde_json::from_str(content).unwrap();
+        assert_eq!(config.display.get("scale_eDP-1"), Some(&2.0));
+        assert_eq!(config.display.get("scale_DP-1"), Some(&1.5));
+    }
 }
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index d846d32..abb3549 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -49,6 +49,8 @@ pub struct Cursor {
     pub panning_gesture_active: bool,
     pub last_click_time: u32,
     pub last_click_window: *mut crate::window::Window,
+    pub right_click_on_bg: bool,
+    pub right_click_on_border: bool,
 }
 
 impl Default for Cursor {
@@ -95,6 +97,8 @@ impl Default for Cursor {
             panning_gesture_active: false,
             last_click_time: 0,
             last_click_window: std::ptr::null_mut(),
+            right_click_on_bg: false,
+            right_click_on_border: false,
         }
     }
 }
@@ -663,6 +667,30 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
         } else {
             0
         };
+
+        if (*event).button == 0x111 && modifiers == 0 {
+            let mut clicked_interactive = false;
+            if let Some(result) = (*server).scene.at(lx, ly) {
+                match result.data {
+                    SceneNodeDataVal::Window(_) | SceneNodeDataVal::LayerSurface(_) | SceneNodeDataVal::ShellSurface(_) | SceneNodeDataVal::LockSurface(_) | SceneNodeDataVal::OverrideRedirect(_) => {
+                        clicked_interactive = true;
+                    }
+                }
+            }
+            if !clicked_interactive {
+                cursor.right_click_on_bg = true;
+                let x = cursor.x() as i32;
+                let y = cursor.y() as i32;
+                let cmd = format!("/home/lsgalante/.local/bin/cce-desktop-menu -x {} -y {}", x, y);
+                (*server).wm.execute_action(&crate::config::Action::Spawn, Some(&cmd));
+
+                seat.focus(Focus::None);
+                (*(*seat).server).wm.dirty_windowing();
+
+                cursor.pressed.insert((*event).button, None);
+                return;
+            }
+        }
         
         let mut matched_pb: Option<crate::config::PointerBind> = None;
         for pb in &(*(*seat).server).wm.pointer_binds {
@@ -774,7 +802,21 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
             && (*border_target_win).tiling_mode != crate::tiling::TilingMode::Fullscreen
         ) {
             let initial_mode = (*border_target_win).tiling_mode;
-            match get_border_zone(border_target_win, lx, ly) {
+            let zone = get_border_zone(border_target_win, lx, ly);
+            if (*event).button == 0x111 && modifiers == 0 && !matches!(zone, BorderZone::None) {
+                cursor.right_click_on_border = true;
+                let x = cursor.x() as i32;
+                let y = cursor.y() as i32;
+                let index = (*border_target_win).ref_key.index;
+                let app_id = (*border_target_win).get_app_id_string().unwrap_or_else(|| "unknown".to_string());
+                let cmd = format!("/home/lsgalante/.local/bin/cce-app-menu -x {} -y {} -i {} -a {}", x, y, index, app_id);
+                (*server).wm.execute_action(&crate::config::Action::Spawn, Some(&cmd));
+
+                cursor.pressed.insert((*event).button, None);
+                return;
+            }
+
+            match zone {
                 BorderZone::Resize(edges) => {
                     if (*event).button == 0x110 { // BTN_LEFT
                         if initial_mode != crate::tiling::TilingMode::Floating {
@@ -1032,6 +1074,16 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                 return;
             }
 
+            if (*event).button == 0x111 && (cursor.right_click_on_bg || cursor.right_click_on_border) {
+                cursor.right_click_on_bg = false;
+                cursor.right_click_on_border = false;
+                if cursor.pressed.is_empty() && seat.op.is_some() {
+                    seat.op_release = true;
+                    (*(*seat).server).wm.dirty_windowing();
+                }
+                return;
+            }
+
             if !should_block_button {
                 ffi::wlr_seat_pointer_notify_button(
                     seat.wlr_seat,
diff --git a/src/server/output.rs b/src/server/output.rs
index a3a6e75..a08ae7f 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -364,12 +364,19 @@ impl Output {
             return Err("Failed to create wlr_scene_output");
         }
 
+        let name_raw = ffi::river_wlr_output_get_name(wlr_output);
+        let name = std::ffi::CStr::from_ptr(name_raw).to_string_lossy();
+        let scale_key = format!("scale_{}", name);
+        let output_scale = (*server).wm.display.get(&scale_key)
+            .map(|&s| s as f32)
+            .unwrap_or((*server).wm.output_scale);
+
         let initial = OutputState {
             state: OutputStateValue::DisabledHard,
             x: 0,
             y: 0,
             mode: OutputMode::None,
-            scale: (*server).wm.output_scale,
+            scale: output_scale,
             transform: ffi::wl_output_transform_WL_OUTPUT_TRANSFORM_NORMAL,
             adaptive_sync: ffi::river_wlr_output_get_adaptive_sync_status(wlr_output) == ffi::wlr_output_adaptive_sync_status_WLR_OUTPUT_ADAPTIVE_SYNC_ENABLED,
             auto_layout: true,
@@ -535,13 +542,17 @@ impl Output {
         let (viewport_w, viewport_h) = self.current.dimensions();
         let zoom = wm.desk_zoom;
 
-        // Dynamic spacing based on zoom to avoid rendering too many lines (LOD)
-        let mut grid_spacing = wm.layout.desktop_grid_scale;
-        while grid_spacing * zoom < 40.0 {
-            grid_spacing *= 2.0;
+        // LOD calculations for cells and gaps
+        let mut cell_size = wm.layout.desktop_grid_scale.max(5.0);
+        let mut gap_size = (wm.layout.desktop_line_width as f64).max(0.0);
+        let mut period = cell_size + gap_size;
+
+        while period * zoom < 40.0 {
+            cell_size *= 2.0;
+            gap_size *= 2.0;
+            period = cell_size + gap_size;
         }
 
-        let line_width = wm.layout.desktop_line_width;
         let grid_color: [f32; 4] = wm.layout.desktop_grid_color;
 
         // Check if cached grid parameters match current parameters
@@ -550,8 +561,8 @@ impl Output {
             && self.last_grid_zoom == zoom
             && self.last_grid_pan_x == wm.desk_pan_x
             && self.last_grid_pan_y == wm.desk_pan_y
-            && self.last_grid_spacing == grid_spacing
-            && self.last_grid_line_width == line_width
+            && self.last_grid_spacing == cell_size
+            && self.last_grid_line_width == gap_size as i32
             && self.last_grid_color == grid_color
         {
             return;
@@ -563,11 +574,11 @@ impl Output {
         self.last_grid_zoom = zoom;
         self.last_grid_pan_x = wm.desk_pan_x;
         self.last_grid_pan_y = wm.desk_pan_y;
-        self.last_grid_spacing = grid_spacing;
-        self.last_grid_line_width = line_width;
+        self.last_grid_spacing = cell_size;
+        self.last_grid_line_width = gap_size as i32;
         self.last_grid_color = grid_color;
 
-        // Clear previous grid lines
+        // Clear previous grid rendering
         ffi::river_scene_tree_clear_children(self.grid_tree);
 
         let min_x = wm.desk_pan_x;
@@ -575,44 +586,41 @@ impl Output {
         let min_y = wm.desk_pan_y;
         let max_y = wm.desk_pan_y + (viewport_h as f64) / zoom;
 
-        // Draw vertical lines
-        let mut x_val = (min_x / grid_spacing).ceil() * grid_spacing;
-        while x_val <= max_x {
-            let rel_x = ((x_val - wm.desk_pan_x) * zoom) as i32;
-            let line_rect = ffi::wlr_scene_rect_create(
-                self.grid_tree,
-                line_width, // width of line
-                viewport_h,
-                grid_color.as_ptr(),
-            );
-            if !line_rect.is_null() {
-                ffi::wlr_scene_node_set_position(
-                    line_rect as *mut ffi::wlr_scene_node,
-                    rel_x,
-                    0,
-                );
+        let min_col = ((min_x - cell_size) / period).floor() as i32;
+        let max_col = (max_x / period).ceil() as i32;
+        let min_row = ((min_y - cell_size) / period).floor() as i32;
+        let max_row = (max_y / period).ceil() as i32;
+
+        for col in min_col..=max_col {
+            let vx = (col as f64) * period;
+            let rel_x = ((vx - wm.desk_pan_x) * zoom) as i32;
+            let rw = (cell_size * zoom) as i32;
+            if rw <= 0 {
+                continue;
             }
-            x_val += grid_spacing;
-        }
 
-        // Draw horizontal lines
-        let mut y_val = (min_y / grid_spacing).ceil() * grid_spacing;
-        while y_val <= max_y {
-            let rel_y = ((y_val - wm.desk_pan_y) * zoom) as i32;
-            let line_rect = ffi::wlr_scene_rect_create(
-                self.grid_tree,
-                viewport_w,
-                line_width, // height of line
-                grid_color.as_ptr(),
-            );
-            if !line_rect.is_null() {
-                ffi::wlr_scene_node_set_position(
-                    line_rect as *mut ffi::wlr_scene_node,
-                    0,
-                    rel_y,
+            for row in min_row..=max_row {
+                let vy = (row as f64) * period;
+                let rel_y = ((vy - wm.desk_pan_y) * zoom) as i32;
+                let rh = (cell_size * zoom) as i32;
+                if rh <= 0 {
+                    continue;
+                }
+
+                let cell_rect = ffi::wlr_scene_rect_create(
+                    self.grid_tree,
+                    rw,
+                    rh,
+                    grid_color.as_ptr(),
                 );
+                if !cell_rect.is_null() {
+                    ffi::wlr_scene_node_set_position(
+                        cell_rect as *mut ffi::wlr_scene_node,
+                        rel_x,
+                        rel_y,
+                    );
+                }
             }
-            y_val += grid_spacing;
         }
     }
 }
diff --git a/src/server/output_manager.rs b/src/server/output_manager.rs
index dd94da0..4a1bd13 100644
--- a/src/server/output_manager.rs
+++ b/src/server/output_manager.rs
@@ -305,6 +305,11 @@ impl OutputManager {
                 break;
             }
 
+            if output.sent.scale != output.current.scale {
+                need_modeset = true;
+                break;
+            }
+
             let wlr_adaptive = ffi::river_wlr_output_get_adaptive_sync_status(wlr_output)
                 == ffi::wlr_output_adaptive_sync_status_WLR_OUTPUT_ADAPTIVE_SYNC_ENABLED;
             if output.sent.adaptive_sync != wlr_adaptive {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 20a8faf..7ac9260 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -102,6 +102,7 @@ pub struct WindowManager {
     pub startup_pids: Vec<(crate::config::StartupConfig, nix::unistd::Pid)>,
     pub status_sender: Option<crate::status_server::StatusSender>,
     pub output_scale: f32,
+    pub display: std::collections::HashMap<String, f64>,
     pub input_rules: Vec<crate::config::InputDeviceConfigRule>,
     pub input_config: crate::config::InputConfig,
     pub last_status_update: std::cell::RefCell<Option<crate::status_server::StatusUpdate>>,
@@ -122,6 +123,7 @@ impl WindowManager {
         self.scheduled.output_config = std::ptr::null_mut();
         self.sent.output_config = std::ptr::null_mut();
         self.output_scale = 1.0;
+        self.display = std::collections::HashMap::new();
         self.input_rules = Vec::new();
         self.input_config = crate::config::InputConfig::default();
         self.mode = WindowManagerMode::Normal;
@@ -166,6 +168,7 @@ impl WindowManager {
         self.shutting_down = false;
         self.layout = crate::config::Layout::default();
         self.output_scale = 1.0;
+        self.display = std::collections::HashMap::new();
         self.has_restored_focused_window = false;
         self.restored_focused_window_mapped = false;
         self.mode_rules = Vec::new();
@@ -2487,6 +2490,26 @@ fn get_closest_tag(x: f64, y: f64) -> i32 {
             let old_pids = std::mem::take(&mut self.startup_pids);
             match crate::config::parse_config(&path, self) {
                 Ok(()) => {
+                    // Update scales of existing outputs from the newly loaded config
+                    let om_outputs = &mut (*self.server).om.outputs as *mut ffi::wl_list;
+                    let mut link = (*om_outputs).next;
+                    while link != om_outputs {
+                        let output = &mut *crate::container_of!(link, crate::output::Output, link);
+                        let wlr_output = output.wlr_output;
+                        if !wlr_output.is_null() {
+                            let name_raw = ffi::river_wlr_output_get_name(wlr_output);
+                            let name = std::ffi::CStr::from_ptr(name_raw).to_string_lossy();
+                            let scale_key = format!("scale_{}", name);
+                            let output_scale = self.display.get(&scale_key)
+                                .map(|&s| s as f32)
+                                .unwrap_or(self.output_scale);
+                            if output.scheduled.scale != output_scale {
+                                output.scheduled.scale = output_scale;
+                            }
+                        }
+                        link = (*link).next;
+                    }
+
                     self.dirty_windowing();
 
                     // Process old PIDs