Browse Source

fix: persist companion window position

alvinreal 1 month ago
parent
commit
c52164b33e
5 changed files with 163 additions and 11 deletions
  1. 69 6
      companion/src/app.rs
  2. 52 5
      companion/src/niri.rs
  3. 31 0
      companion/src/state.rs
  4. 10 0
      docs/companion.md
  5. 1 0
      src/companion/manager.ts

+ 69 - 6
companion/src/app.rs

@@ -10,7 +10,10 @@ use eframe::egui;
 use crate::gifs::Gifs;
 use crate::niri;
 use crate::screen::primary_size;
-use crate::state::{read_state, start_watcher, CompanionConfigState, SessionInfo};
+use crate::state::{
+    read_state, start_watcher, write_project_window_position, CompanionConfigState, SessionInfo,
+    WindowPositionState,
+};
 
 const DEFAULT_SIZE: f32 = 120.0;
 const GAP: f32 = 10.0;
@@ -29,7 +32,10 @@ const MENU_POS_KEY: &str = "companion_menu_pos";
 #[derive(Clone, Debug, PartialEq, Eq)]
 struct WindowGeometryKey {
     session_id: String,
+    project_key: String,
     position: String,
+    custom_x: Option<i32>,
+    custom_y: Option<i32>,
     size_px: u32,
     cols: u32,
     rows: u32,
@@ -103,6 +109,20 @@ pub(crate) fn place_window(position: &str, screen: [f32; 2], win: [f32; 2]) -> [
     [x.clamp(GAP, x_max), y.clamp(GAP, y_max)]
 }
 
+fn clamp_window_position(pos: [f32; 2], screen: [f32; 2], win: [f32; 2]) -> [f32; 2] {
+    let x_max = (screen[0] - win[0] - GAP).max(GAP);
+    let y_max = (screen[1] - win[1] - GAP).max(GAP);
+    [pos[0].clamp(GAP, x_max), pos[1].clamp(GAP, y_max)]
+}
+
+fn project_key(cwd: &str) -> String {
+    std::path::Path::new(cwd)
+        .canonicalize()
+        .ok()
+        .and_then(|path| path.to_str().map(str::to_string))
+        .unwrap_or_else(|| cwd.to_string())
+}
+
 fn cell_rects(agents: usize, cols: usize, rows: usize, cell: f32) -> Vec<egui::Rect> {
     let mut rects = Vec::with_capacity(agents);
     let full_rows = agents / cols;
@@ -166,6 +186,8 @@ pub struct CompanionApp {
     has_modern_config: bool,
     applied_config: Option<ConfigKey>,
     applied_geometry: Option<WindowGeometryKey>,
+    window_positions: std::collections::BTreeMap<String, WindowPositionState>,
+    drag_project_key: Option<String>,
     niri_generation: Arc<AtomicU64>,
 }
 
@@ -174,6 +196,7 @@ impl CompanionApp {
         let state_path = crate::state::state_file_path();
         let state = read_state(&state_path);
         let sessions = state.sessions;
+        let window_positions = state.window_positions;
 
         let mut initial_size = DEFAULT_SIZE;
         let mut position = "bottom-right".to_string();
@@ -195,6 +218,8 @@ impl CompanionApp {
             has_modern_config,
             applied_config,
             applied_geometry: None,
+            window_positions,
+            drag_project_key: None,
             niri_generation: Arc::new(AtomicU64::new(0)),
         }
     }
@@ -204,6 +229,7 @@ impl CompanionApp {
             while self.rx.try_recv().is_ok() {}
             let state = read_state(&self.state_path);
             self.sessions = state.sessions;
+            self.window_positions = state.window_positions;
             self.has_modern_config = state.config.is_some();
             let next_config = config_key(state.config.as_ref());
             let config_changed = self.applied_config != next_config;
@@ -268,6 +294,8 @@ impl eframe::App for CompanionApp {
         };
 
         let session = self.sessions[selected_idx].clone();
+        let project_key = project_key(&session.cwd);
+        let saved_position = self.window_positions.get(&project_key).copied();
         let agent_uris: Vec<String> = if session.active_agents.is_empty() {
             vec![self.gifs.uri("intro")]
         } else {
@@ -283,7 +311,10 @@ impl eframe::App for CompanionApp {
 
         let geometry = WindowGeometryKey {
             session_id: session.session_id.clone(),
+            project_key: project_key.clone(),
             position: self.position.clone(),
+            custom_x: saved_position.map(|pos| pos.x.round() as i32),
+            custom_y: saved_position.map(|pos| pos.y.round() as i32),
             size_px: self.size.round() as u32,
             cols: cols as u32,
             rows: rows as u32,
@@ -292,17 +323,42 @@ impl eframe::App for CompanionApp {
         };
         if self.applied_geometry.as_ref() != Some(&geometry) {
             ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(win_w, win_h)));
-            let pos = place_window(&self.position, self.screen, [win_w, win_h]);
+            let pos = saved_position
+                .map(|pos| clamp_window_position([pos.x, pos.y], self.screen, [win_w, win_h]))
+                .unwrap_or_else(|| place_window(&self.position, self.screen, [win_w, win_h]));
             ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(
                 pos[0], pos[1],
             )));
             self.applied_geometry = Some(geometry);
-            self.spawn_niri_fallback([win_w, win_h]);
+            self.spawn_niri_fallback([win_w, win_h], saved_position);
         }
 
-        if ctx.input(|i| i.pointer.primary_down()) {
+        let menu_open = ctx.data(|d| {
+            d.get_temp::<bool>(egui::Id::new(MENU_OPEN_KEY))
+                .unwrap_or(false)
+        });
+        if !menu_open && ctx.input(|i| i.pointer.primary_pressed()) {
+            self.drag_project_key = Some(project_key.clone());
+        }
+        if self.drag_project_key.is_some() && ctx.input(|i| i.pointer.primary_down()) {
             ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
         }
+        if ctx.input(|i| i.pointer.primary_released()) {
+            if let Some(project_key) = self.drag_project_key.take() {
+                if let Some(rect) = ctx.input(|i| i.viewport().outer_rect) {
+                    let position = WindowPositionState {
+                        x: rect.min.x,
+                        y: rect.min.y,
+                    };
+                    if write_project_window_position(&self.state_path, &project_key, position)
+                        .is_ok()
+                    {
+                        self.window_positions.insert(project_key, position);
+                        self.applied_geometry = None;
+                    }
+                }
+            }
+        }
 
         if ctx.input(|i| i.pointer.secondary_released()) {
             let cursor = ctx.input(|i| i.pointer.interact_pos()).unwrap_or_default();
@@ -329,17 +385,20 @@ impl eframe::App for CompanionApp {
 }
 
 impl CompanionApp {
-    fn spawn_niri_fallback(&self, win_size: [f32; 2]) {
+    fn spawn_niri_fallback(&self, win_size: [f32; 2], saved_position: Option<WindowPositionState>) {
         let socket = match std::env::var("NIRI_SOCKET") {
             Ok(socket) if !socket.is_empty() => socket,
             _ => return,
         };
-        let desired = place_window(&self.position, self.screen, win_size);
+        let desired = saved_position
+            .map(|pos| clamp_window_position([pos.x, pos.y], self.screen, win_size))
+            .unwrap_or_else(|| place_window(&self.position, self.screen, win_size));
         if !desired[0].is_finite() || !desired[1].is_finite() {
             return;
         }
         let generation = self.niri_generation.fetch_add(1, Ordering::Relaxed) + 1;
         let position = self.position.clone();
+        let target_position = saved_position.map(|pos| [pos.x, pos.y]);
         let screen = self.screen;
         let niri_generation = Arc::clone(&self.niri_generation);
         std::thread::spawn(move || {
@@ -349,6 +408,7 @@ impl CompanionApp {
                 generation,
                 niri_generation,
                 position,
+                target_position,
                 screen,
                 win_size,
             );
@@ -652,7 +712,10 @@ mod tests {
     fn geometry_key_changes_with_layout_inputs() {
         let base = WindowGeometryKey {
             session_id: "a".into(),
+            project_key: "/a".into(),
             position: "bottom-right".into(),
+            custom_x: None,
+            custom_y: None,
             size_px: 120,
             cols: 1,
             rows: 1,

+ 52 - 5
companion/src/niri.rs

@@ -45,6 +45,7 @@ pub fn retry_move_current_window(
     generation: u64,
     current_generation: Arc<AtomicU64>,
     position: String,
+    target_position: Option<[f32; 2]>,
     screen: [f32; 2],
     win_size: [f32; 2],
 ) {
@@ -53,7 +54,9 @@ pub fn retry_move_current_window(
         if current_generation.load(Ordering::Relaxed) != generation {
             return;
         }
-        let Some((id, delta)) = resolve_move(&socket, pid, &position, screen, win_size) else {
+        let Some((id, delta)) =
+            resolve_move(&socket, pid, &position, target_position, screen, win_size)
+        else {
             continue;
         };
         if move_window(&socket, &id, delta).is_ok() {
@@ -66,6 +69,7 @@ fn resolve_move(
     socket: &str,
     pid: u32,
     position: &str,
+    target_position: Option<[f32; 2]>,
     screen: [f32; 2],
     win_size: [f32; 2],
 ) -> Option<(String, [i32; 2])> {
@@ -96,6 +100,7 @@ fn resolve_move(
         outputs_json,
         pid,
         position,
+        target_position,
         screen,
         win_size,
     )
@@ -106,6 +111,7 @@ fn resolve_move_from_json(
     outputs_json: Option<&[u8]>,
     pid: u32,
     position: &str,
+    target_position: Option<[f32; 2]>,
     screen: [f32; 2],
     win_size: [f32; 2],
 ) -> Option<(String, [i32; 2])> {
@@ -121,8 +127,17 @@ fn resolve_move_from_json(
             width: screen[0] as f64,
             height: screen[1] as f64,
         });
-    let desired =
-        place_window_on_output(position, output, [win_size[0] as f64, win_size[1] as f64]);
+    let desired = target_position
+        .map(|pos| {
+            clamp_position_on_output(
+                [pos[0] as f64, pos[1] as f64],
+                output,
+                [win_size[0] as f64, win_size[1] as f64],
+            )
+        })
+        .unwrap_or_else(|| {
+            place_window_on_output(position, output, [win_size[0] as f64, win_size[1] as f64])
+        });
     let dx = (desired[0] - current[0]).round() as i32;
     let dy = (desired[1] - current[1]).round() as i32;
     if dx.abs() <= 1 && dy.abs() <= 1 {
@@ -192,6 +207,21 @@ fn place_window_on_output(
     [x.clamp(x_min, x_max), y.clamp(y_min, y_max)]
 }
 
+fn clamp_position_on_output(
+    position: [f64; 2],
+    output: NiriOutputLogical,
+    win_size: [f64; 2],
+) -> [f64; 2] {
+    let x_min = output.x + GAP;
+    let y_min = output.y + GAP;
+    let x_max = (output.x + output.width - win_size[0] - GAP).max(x_min);
+    let y_max = (output.y + output.height - win_size[1] - GAP).max(y_min);
+    [
+        position[0].clamp(x_min, x_max),
+        position[1].clamp(y_min, y_max),
+    ]
+}
+
 fn matches_window(win: &NiriWindow, pid: u32) -> bool {
     win.pid == Some(pid)
         && win.is_floating == Some(true)
@@ -232,8 +262,9 @@ fn format_delta(delta: i32) -> String {
 #[cfg(test)]
 mod tests {
     use super::{
-        build_move_args, matches_window, output_for_position, parse_outputs,
-        place_window_on_output, resolve_move_from_json, NiriOutputLogical, NiriWindow,
+        build_move_args, clamp_position_on_output, matches_window, output_for_position,
+        parse_outputs, place_window_on_output, resolve_move_from_json, NiriOutputLogical,
+        NiriWindow,
     };
 
     const FIXTURE: &str = r#"[
@@ -374,6 +405,7 @@ mod tests {
                 Some(OUTPUTS.as_bytes()),
                 1234,
                 "bottom-right",
+                None,
                 [2550.0, 1100.0],
                 [360.0, 240.0],
             ),
@@ -395,6 +427,7 @@ mod tests {
                 Some(outputs.as_bytes()),
                 1234,
                 "top-left",
+                None,
                 [10000.0, 5000.0],
                 [120.0, 120.0],
             ),
@@ -416,6 +449,20 @@ mod tests {
         );
     }
 
+    #[test]
+    fn custom_position_is_clamped_to_output() {
+        let output = NiriOutputLogical {
+            x: 0.0,
+            y: 0.0,
+            width: 500.0,
+            height: 300.0,
+        };
+        assert_eq!(
+            clamp_position_on_output([460.0, -20.0], output, [120.0, 120.0]),
+            [370.0, 10.0]
+        );
+    }
+
     #[test]
     fn output_selection_prefers_current_window_output() {
         let outputs = vec![

+ 31 - 0
companion/src/state.rs

@@ -1,4 +1,5 @@
 use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
 use std::path::PathBuf;
 use std::sync::mpsc::{self, Receiver, Sender};
 use std::time::Duration;
@@ -17,6 +18,14 @@ pub struct CompanionState {
     pub sessions: Vec<SessionInfo>,
     #[serde(default)]
     pub config: Option<CompanionConfigState>,
+    #[serde(default)]
+    pub window_positions: BTreeMap<String, WindowPositionState>,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
+pub struct WindowPositionState {
+    pub x: f32,
+    pub y: f32,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -57,6 +66,28 @@ pub fn read_state(path: &std::path::Path) -> CompanionState {
         .unwrap_or_default()
 }
 
+pub fn write_project_window_position(
+    path: &std::path::Path,
+    project: &str,
+    position: WindowPositionState,
+) -> std::io::Result<()> {
+    if project.trim().is_empty() || !position.x.is_finite() || !position.y.is_finite() {
+        return Ok(());
+    }
+
+    let mut state = read_state(path);
+    state.window_positions.insert(project.to_string(), position);
+
+    if let Some(parent) = path.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    let tmp = path.with_extension("json.tmp");
+    let json = serde_json::to_string(&state).map_err(std::io::Error::other)?;
+    std::fs::write(&tmp, json)?;
+    std::fs::rename(tmp, path)?;
+    Ok(())
+}
+
 /// Starts a background thread that polls the state file for changes.
 /// Returns a receiver that fires whenever the file content changes.
 pub fn start_watcher(path: PathBuf) -> Receiver<()> {

+ 10 - 0
docs/companion.md

@@ -29,6 +29,16 @@ You can enable the companion by adding a `companion` section to your setting con
   - `medium` (120px) (default)
   - `large` (160px)
 
+### Remembered Window Position
+
+You can drag the companion window to a custom location. The companion remembers
+the last dragged position per project and restores it the next time that project
+opens. If no custom position is saved for a project, the configured
+`companion.position` corner is used.
+
+Saved positions are clamped to the current screen so the companion stays visible
+after monitor or resolution changes.
+
 ---
 
 ## Installer Flag

+ 1 - 0
src/companion/manager.ts

@@ -22,6 +22,7 @@ interface CompanionSession {
 interface CompanionState {
   version: 1;
   sessions: CompanionSession[];
+  window_positions?: Record<string, { x: number; y: number }>;
   config?: {
     enabled: boolean;
     position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';