-
Notifications
You must be signed in to change notification settings - Fork 2
Saving monitor position when disabling or reenabling a monitor #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
- Implement snap-to-boundary behavior for default move controls (Arrow keys, hjkl). - Add Shift modifier (Shift+Arrow, Shift+HJKL) for 10px incremental movement. - Add get_geometry to Monitor struct for bounds calculation. - Update Map::handle_events to support new move logic. - Fix render_map test expectation to match zoom behavior. - Add/Update tests for move mode.
Introduces functionality to save and load monitor positions and scales to a JSON file in the user's config directory. Updates monitor enable/disable logic to preserve and restore state, and ensures state is saved on exit and after relevant mode changes. Includes tests for monitor state persistence and updates documentation for clarity.
Removes resetting monitor position to (0,0) when no saved position is found, preserving the current position which may come from persistent state. Also updates scale assignment to prefer saved_scale, then existing scale, then default to 1.0.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.
commit b810c1d Author: Henrik <57109108+Henriklmao@users.noreply.github.com> Date: Mon Dec 15 17:09:37 2025 +0100 Preserve monitor position if no saved position exists Removes resetting monitor position to (0,0) when no saved position is found, preserving the current position which may come from persistent state. Also updates scale assignment to prefer saved_scale, then existing scale, then default to 1.0. commit 0a33ca5 Author: Henrik <57109108+Henriklmao@users.noreply.github.com> Date: Mon Dec 15 17:05:38 2025 +0100 Add persistent monitor state saving and loading Introduces functionality to save and load monitor positions and scales to a JSON file in the user's config directory. Updates monitor enable/disable logic to preserve and restore state, and ensures state is saved on exit and after relevant mode changes. Includes tests for monitor state persistence and updates documentation for clarity.
Gitisshit
|
Just to update a little. I added Dan Kingsley's Changes to my fork, to avoid any merge conflicts. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 15 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let (width, height) = if rotation == Rotation::Deg90 || rotation == Rotation::Deg270 { | ||
| (mode.unwrap().height, mode.unwrap().width) | ||
| } else { | ||
| (mode.unwrap().width, mode.unwrap().height) |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to the issue above, calling unwrap() twice on the same mode value (lines 208 and 210) is redundant. The unwrapped mode should be stored in a variable to avoid repeated unwrapping and improve code clarity.
| let (width, height) = if rotation == Rotation::Deg90 || rotation == Rotation::Deg270 { | |
| (mode.unwrap().height, mode.unwrap().width) | |
| } else { | |
| (mode.unwrap().width, mode.unwrap().height) | |
| let mode = mode.unwrap(); | |
| let (width, height) = if rotation == Rotation::Deg90 || rotation == Rotation::Deg270 { | |
| (mode.height, mode.width) | |
| } else { | |
| (mode.width, mode.height) |
| fn snap_vertical(app:&mut App, direction: i32) { | ||
| let selected_index = app.selected_monitor; | ||
| let mut targets = vec![0.0]; | ||
|
|
||
| for (i, monitor) in app.monitors.iter().enumerate() { | ||
| if i == selected_index || !monitor.enabled { continue; } | ||
| let (_, y, _, h) = monitor.get_geometry(); | ||
| targets.push(y); | ||
| targets.push(y + h); | ||
| targets.push(y + h / 2.0); | ||
| } | ||
|
|
||
| let (_, sy, _, sh) = app.monitors[selected_index].get_geometry(); | ||
| let sources = vec![sy, sy + sh, sy + sh / 2.0]; | ||
|
|
||
| let mut best_delta: Option<f64> = None; | ||
|
|
||
| for s in &sources { | ||
| for t in &targets { | ||
| let diff = t - s; | ||
| if (direction < 0 && diff < -0.1) || (direction > 0 && diff > 0.1) { | ||
| match best_delta { | ||
| None => best_delta = Some(diff), | ||
| Some(current) => { | ||
| if diff.abs() < current.abs() { | ||
| best_delta = Some(diff); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if let Some(delta) = best_delta { | ||
| app.monitors[selected_index].move_vertical(delta.round() as i32); | ||
| } | ||
| } | ||
|
|
||
| fn move_horizontal(app:&mut App, direction: i32) { | ||
| app.monitors[app.selected_monitor].move_horizontal(direction); | ||
| } | ||
| fn snap_horizontal(app:&mut App, direction: i32) { | ||
| let selected_index = app.selected_monitor; | ||
| let mut targets = vec![0.0]; | ||
|
|
||
| for (i, monitor) in app.monitors.iter().enumerate() { | ||
| if i == selected_index || !monitor.enabled { continue; } | ||
| let (x, _, w, _) = monitor.get_geometry(); | ||
| targets.push(x); | ||
| targets.push(x + w); | ||
| targets.push(x + w / 2.0); | ||
| } | ||
|
|
||
| let (sx, _, sw, _) = app.monitors[selected_index].get_geometry(); | ||
| let sources = vec![sx, sx + sw, sx + sw / 2.0]; | ||
|
|
||
| let mut best_delta: Option<f64> = None; | ||
|
|
||
| for s in &sources { | ||
| for t in &targets { | ||
| let diff = t - s; | ||
| if (direction < 0 && diff < -0.1) || (direction > 0 && diff > 0.1) { | ||
| match best_delta { | ||
| None => best_delta = Some(diff), | ||
| Some(current) => { | ||
| if diff.abs() < current.abs() { | ||
| best_delta = Some(diff); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if let Some(delta) = best_delta { | ||
| app.monitors[selected_index].move_horizontal(delta.round() as i32); | ||
| } | ||
| } |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The snap_vertical and snap_horizontal functions have significant code duplication (lines 111-147 and 152-188). Consider extracting the common snapping logic into a helper function that takes an axis parameter and coordinate extractors to reduce duplication and improve maintainability.
| for s in &sources { | ||
| for t in &targets { | ||
| let diff = t - s; | ||
| if (direction < 0 && diff < -0.1) || (direction > 0 && diff > 0.1) { |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The magic number -0.1 and 0.1 are used as epsilon values for floating point comparison. Consider defining these as named constants (e.g., SNAP_EPSILON) at the module level to make the code more maintainable and the purpose clearer.
| if (direction < 0 && diff < -0.1) || (direction > 0 && diff > 0.1) { | |
| if (direction < 0 && diff < -SNAP_EPSILON) || (direction > 0 && diff > SNAP_EPSILON) { |
| use serde::Deserialize; | ||
|
|
||
| #[derive(Debug, Clone, Deserialize, PartialEq)] | ||
| pub enum Rotation { | ||
| Normal, | ||
| Deg90, | ||
| Deg180, | ||
| Deg270, | ||
| } | ||
|
|
||
| impl Default for Rotation { | ||
| fn default() -> Self { | ||
| Rotation::Normal | ||
| } | ||
| } | ||
|
|
||
| impl Rotation { | ||
| pub fn from_transform(transform: &Option<String>) -> Self { | ||
| match transform.as_deref() { | ||
| Some("90") => Rotation::Deg90, | ||
| Some("180") => Rotation::Deg180, | ||
| Some("270") => Rotation::Deg270, | ||
| _ => Rotation::Normal, | ||
| } | ||
| } | ||
|
|
||
| pub fn to_transform(&self) -> &str { | ||
| match self { | ||
| Rotation::Normal => "normal", | ||
| Rotation::Deg90 => "90", | ||
| Rotation::Deg180 => "180", | ||
| Rotation::Deg270 => "270", | ||
| } | ||
| } | ||
|
|
||
| pub fn to_hyprland(&self) -> i32 { | ||
| match self { | ||
| Rotation::Normal => 0, | ||
| Rotation::Deg90 => 1, | ||
| Rotation::Deg180 => 2, | ||
| Rotation::Deg270 => 3, | ||
| } | ||
| } | ||
|
|
||
| pub fn cycle(&self) -> Self { | ||
| match self { | ||
| Rotation::Normal => Rotation::Deg90, | ||
| Rotation::Deg90 => Rotation::Deg180, | ||
| Rotation::Deg180 => Rotation::Deg270, | ||
| Rotation::Deg270 => Rotation::Normal, | ||
| } | ||
| } | ||
| } |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new Rotation module lacks any test coverage. Given the comprehensive testing in this project, tests should be added to verify the rotation cycle, transform conversions (from_transform, to_transform, to_hyprland), and the default behavior.
| let rotation = Rotation::from_transform(&self.transform); | ||
| format!( | ||
| "monitor = {}, {}x{}@{}, {}x{}, {}", | ||
| "monitor = {}, {}x{}@{}, {}x{}, {}, transform,{}", |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The format string appears to have incorrect syntax with "transform," appearing as a literal string followed by a placeholder. According to Hyprland documentation, the monitor configuration format should be: "monitor = name, resolution@refresh, position, scale, transform, value". The current format has an extra comma and the word "transform" as a literal. This should likely be: "monitor = {}, {}x{}@{}, {}x{}, {}, transform, {}" with proper spacing.
| "monitor = {}, {}x{}@{}, {}x{}, {}, transform,{}", | |
| "monitor = {}, {}x{}@{}, {}x{}, {}, {}, {}", |
| let x = self.position.clone().unwrap().x as f64; | ||
| let y = self.position.clone().unwrap().y as f64; |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to line 164, the position is cloned and unwrapped separately for x and y. Consider cloning once or using a reference to avoid redundant operations.
| let x = self.position.clone().unwrap().x as f64; | |
| let y = self.position.clone().unwrap().y as f64; | |
| let pos = self.position.clone().unwrap(); | |
| let x = pos.x as f64; | |
| let y = pos.y as f64; |
| fn snap_vertical(app:&mut App, direction: i32) { | ||
| let selected_index = app.selected_monitor; | ||
| let mut targets = vec![0.0]; | ||
|
|
||
| for (i, monitor) in app.monitors.iter().enumerate() { | ||
| if i == selected_index || !monitor.enabled { continue; } | ||
| let (_, y, _, h) = monitor.get_geometry(); | ||
| targets.push(y); | ||
| targets.push(y + h); | ||
| targets.push(y + h / 2.0); | ||
| } | ||
|
|
||
| let (_, sy, _, sh) = app.monitors[selected_index].get_geometry(); | ||
| let sources = vec![sy, sy + sh, sy + sh / 2.0]; | ||
|
|
||
| let mut best_delta: Option<f64> = None; | ||
|
|
||
| for s in &sources { | ||
| for t in &targets { | ||
| let diff = t - s; | ||
| if (direction < 0 && diff < -0.1) || (direction > 0 && diff > 0.1) { | ||
| match best_delta { | ||
| None => best_delta = Some(diff), | ||
| Some(current) => { | ||
| if diff.abs() < current.abs() { | ||
| best_delta = Some(diff); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if let Some(delta) = best_delta { | ||
| app.monitors[selected_index].move_vertical(delta.round() as i32); | ||
| } | ||
| } | ||
|
|
||
| fn move_horizontal(app:&mut App, direction: i32) { | ||
| app.monitors[app.selected_monitor].move_horizontal(direction); | ||
| } | ||
| fn snap_horizontal(app:&mut App, direction: i32) { | ||
| let selected_index = app.selected_monitor; | ||
| let mut targets = vec![0.0]; | ||
|
|
||
| for (i, monitor) in app.monitors.iter().enumerate() { | ||
| if i == selected_index || !monitor.enabled { continue; } | ||
| let (x, _, w, _) = monitor.get_geometry(); | ||
| targets.push(x); | ||
| targets.push(x + w); | ||
| targets.push(x + w / 2.0); | ||
| } | ||
|
|
||
| let (sx, _, sw, _) = app.monitors[selected_index].get_geometry(); | ||
| let sources = vec![sx, sx + sw, sx + sw / 2.0]; | ||
|
|
||
| let mut best_delta: Option<f64> = None; | ||
|
|
||
| for s in &sources { | ||
| for t in &targets { | ||
| let diff = t - s; | ||
| if (direction < 0 && diff < -0.1) || (direction > 0 && diff > 0.1) { | ||
| match best_delta { | ||
| None => best_delta = Some(diff), | ||
| Some(current) => { | ||
| if diff.abs() < current.abs() { | ||
| best_delta = Some(diff); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if let Some(delta) = best_delta { | ||
| app.monitors[selected_index].move_horizontal(delta.round() as i32); | ||
| } | ||
| } |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new snap_vertical and snap_horizontal functions implementing smart monitor positioning lack dedicated test coverage. While there's a basic snap test in main.rs (lines 401-408), comprehensive tests should verify edge cases such as snapping to multiple monitor edges, snapping when no valid target exists, and snapping in both directions.
| }); | ||
| } | ||
| // If no saved_position (from disable), keep the current position | ||
| // which might have been loaded from the persistent state file |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When enabling a monitor, if saved_position exists it's restored, but if it doesn't exist, the comment says "keep the current position which might have been loaded from the persistent state file". However, there's no fallback to set a default position if neither saved_position nor a current position exists. This could leave a monitor in an undefined state if position is None. Consider adding a fallback to set a default position like (0, 0) if no position is available.
| // which might have been loaded from the persistent state file | |
| // which might have been loaded from the persistent state file. | |
| // If position is still None, set a default position (0, 0) | |
| if monitor.position.is_none() { | |
| monitor.position = Some(Position { x: 0, y: 0 }); | |
| } |
| "monitor = {}, {}x{}@{}, {}x{}, {}, transform,{}", | ||
| self.name, | ||
| mode.width, mode.height, mode.refresh, | ||
| self.position.clone().unwrap().x, self.position.clone().unwrap().y, |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The position is cloned and unwrapped twice on the same line for x and y coordinates. This is inefficient. Consider cloning once and accessing both fields, or better yet, using a reference to avoid the unnecessary clones.
| fn disable_monitor(app:&mut App) { | ||
| app.monitors[app.selected_monitor].enabled = false; | ||
| let monitor = &mut app.monitors[app.selected_monitor]; | ||
| monitor.enabled = false; | ||
| monitor.saved_position = monitor.position.clone(); | ||
| monitor.saved_scale = monitor.scale; | ||
| } | ||
|
|
||
| fn enable_monitor(app:&mut App) { | ||
| app.monitors[app.selected_monitor].enabled = true; | ||
| app.monitors[app.selected_monitor].position = Some( | ||
| Position { | ||
| x: 0, | ||
| y: 0, | ||
| } | ||
| ); | ||
| app.monitors[app.selected_monitor].scale = Some(1.0); | ||
| let monitor = &mut app.monitors[app.selected_monitor]; | ||
| monitor.enabled = true; | ||
| if let Some(saved_pos) = &monitor.saved_position { | ||
| monitor.position = Some(Position { | ||
| x: saved_pos.x, | ||
| y: saved_pos.y, | ||
| }); | ||
| } | ||
| // If no saved_position (from disable), keep the current position | ||
| // which might have been loaded from the persistent state file | ||
| monitor.scale = monitor.saved_scale.or_else(|| monitor.scale).or(Some(1.0)); | ||
| } |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The updated disable_monitor and enable_monitor functions now save and restore position/scale, but lack test coverage for this new behavior. Given the comprehensive testing in the project, tests should verify that disabling a monitor saves its position and scale, and enabling it restores these values.
Added 'set display rotation' to the feature list and corrected the example monitors_config_path in the setup instructions according to https://wiki.hypr.land/Configuring/Start/
This pull request introduces persistent saving and loading of monitor states (positions and scales), improving user experience by remembering display configurations across sessions. It also enhances the enable/disable logic for monitors and updates the documentation for clarity.
Persistent Monitor State Management:
MonitorStatestruct and methods (save_monitor_state,load_monitor_state) inconfiguration.rsto serialize and deserialize monitor positions and scales to a JSON file in the user's config directory. Includes unit tests for this functionality. [1] [2] [3]main.rs. Saves the current state on exit and after relevant actions (move/scale mode exit, configuration save). [1] [2] [3] [4] [5] [6] [7]Monitor Enable/Disable Enhancements:
list.rsto remember previous position and scale when disabling, and restore them when enabling a monitor.Monitorstruct to includesaved_positionandsaved_scalefields, ensuring these are not serialized.Testing and Utilities:
saved_positionandsaved_scalefields inMonitor. [1] [2]Documentation Improvements:
README.mdfor improved formatting, clarified installation steps, and added code block formatting for commands. [1] [2] [3]These changes collectively make display configuration more robust and user-friendly by persisting monitor layouts and improving the clarity of project documentation.