Initial commit
This commit is contained in:
commit
c5136331b4
16 changed files with 4556 additions and 0 deletions
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Rust build artifacts
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Editor and IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS artifacts
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Runtime files
|
||||||
|
*.sock
|
||||||
|
*.pid
|
||||||
1025
Cargo.lock
generated
Normal file
1025
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[package]
|
||||||
|
name = "breadmon"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "TUI monitor manager for Hyprland"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "breadmon"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "signal", "sync", "time", "net", "io-util"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
anyhow = "1"
|
||||||
|
dirs = "5"
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = "symbols"
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Breadway
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
103
README.md
Normal file
103
README.md
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# breadmon
|
||||||
|
|
||||||
|
A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply changes live via `hyprctl`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Hyprland compositor (the `hyprctl` binary must be on `PATH`)
|
||||||
|
- Rust toolchain (to build from source)
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is written to `target/release/breadmon`.
|
||||||
|
|
||||||
|
If you use the bread ecosystem, `bakery` can install it instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
bread modules install /path/to/breadmon
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
breadmon
|
||||||
|
```
|
||||||
|
|
||||||
|
The TUI opens with four tabs, switchable by number key or mouse click:
|
||||||
|
|
||||||
|
| Key | Tab |
|
||||||
|
|-----|-----|
|
||||||
|
| `1` / F1 | Layout |
|
||||||
|
| `2` / F2 | Config |
|
||||||
|
| `3` / F3 | Mirror |
|
||||||
|
| `4` / F4 | Profiles |
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
A scaled canvas showing all connected monitors. The selected monitor is highlighted.
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `hjkl` / arrow keys | Move selected monitor 1 px |
|
||||||
|
| `Shift+hjkl` | Move selected monitor 10 px |
|
||||||
|
| `Tab` / `n` | Cycle to next monitor |
|
||||||
|
| `Shift+Tab` / `p` | Cycle to previous monitor |
|
||||||
|
| `0` | Auto-arrange monitors left-to-right with no gaps |
|
||||||
|
| `[` / `]` | Zoom canvas out / in |
|
||||||
|
| Mouse drag | Drag monitor to reposition |
|
||||||
|
|
||||||
|
Monitors snap to each other's edges when dragged or nudged within 10 px.
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
Per-monitor settings for the currently selected display.
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `jk` / `Tab` | Move between fields |
|
||||||
|
| `hl` / scroll | Cycle field value |
|
||||||
|
| `,` / `.` | Scale −0.1 / +0.1 |
|
||||||
|
| `[` / `]` | Switch to previous / next monitor |
|
||||||
|
| `Enter` | Commit scale edit and apply |
|
||||||
|
| `Esc` | Discard pending edits |
|
||||||
|
|
||||||
|
Fields: Resolution, Refresh rate, Scale, Transform (rotation/flip), VRR, DPMS, Mirror of.
|
||||||
|
|
||||||
|
The header shows the monitor's PPI and a suggested fractional scale when physical dimensions are available.
|
||||||
|
|
||||||
|
### Mirror
|
||||||
|
|
||||||
|
Finds the best common mode between two monitors and sets one to mirror the other. Use `Tab` to move focus between source and target, `hl` to cycle the selection, `Enter` to confirm.
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
Named snapshots of the current monitor configuration, stored as TOML files.
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `jk` | Navigate profile list |
|
||||||
|
| `Enter` | Load selected profile |
|
||||||
|
| `d d` | Delete selected profile (double-press within 3 s) |
|
||||||
|
| `Tab` | Move focus to name input / save button |
|
||||||
|
|
||||||
|
Profiles are saved to `~/.config/breadmon/profiles/`.
|
||||||
|
|
||||||
|
### Global keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `a` | Apply current configuration via `hyprctl` |
|
||||||
|
| `s` | Save current configuration as a profile |
|
||||||
|
| `r` | Refresh monitor list from Hyprland |
|
||||||
|
| `Ctrl+Z` | Undo last change (up to 20 steps) |
|
||||||
|
| `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) |
|
||||||
|
|
||||||
|
breadmon also listens on Hyprland's event socket and reloads the monitor list automatically when a display is connected or disconnected.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
Profiles are plain TOML files under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab; there is no hand-written config file.
|
||||||
12
bakery.toml
Normal file
12
bakery.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
name = "breadmon"
|
||||||
|
description = "TUI monitor manager for Hyprland"
|
||||||
|
binaries = ["breadmon"]
|
||||||
|
system_deps = []
|
||||||
|
optional_system_deps = ["hyprland"]
|
||||||
|
bread_deps = []
|
||||||
|
|
||||||
|
[config]
|
||||||
|
dir = "~/.config/breadmon"
|
||||||
|
|
||||||
|
[install]
|
||||||
|
post_install = []
|
||||||
248
src/layout.rs
Normal file
248
src/layout.rs
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
use crate::monitor::Monitor;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LayoutState {
|
||||||
|
pub selected: usize,
|
||||||
|
pub snap_threshold: i32,
|
||||||
|
pub zoom: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LayoutState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
selected: 0,
|
||||||
|
snap_threshold: 10,
|
||||||
|
zoom: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayoutState {
|
||||||
|
pub fn clamp_selected(&mut self, count: usize) {
|
||||||
|
if count == 0 {
|
||||||
|
self.selected = 0;
|
||||||
|
} else if self.selected >= count {
|
||||||
|
self.selected = count - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self, count: usize) {
|
||||||
|
if count > 0 {
|
||||||
|
self.selected = (self.selected + 1) % count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(&mut self, count: usize) {
|
||||||
|
if count > 0 {
|
||||||
|
self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snap a monitor being moved to the edges of its neighbours.
|
||||||
|
/// Returns the adjusted (x, y) after snapping.
|
||||||
|
pub fn snap_position(
|
||||||
|
moving_idx: usize,
|
||||||
|
new_x: i32,
|
||||||
|
new_y: i32,
|
||||||
|
monitors: &[Monitor],
|
||||||
|
threshold: i32,
|
||||||
|
) -> (i32, i32) {
|
||||||
|
let mw = monitors[moving_idx].world_width() as i32;
|
||||||
|
let mh = monitors[moving_idx].world_height() as i32;
|
||||||
|
|
||||||
|
// Candidate snaps: (snapped_x, snapped_y, priority)
|
||||||
|
// We collect the closest snap in each axis independently
|
||||||
|
let mut best_x_dist = threshold + 1;
|
||||||
|
let mut best_y_dist = threshold + 1;
|
||||||
|
let mut snap_x = new_x;
|
||||||
|
let mut snap_y = new_y;
|
||||||
|
|
||||||
|
for (i, other) in monitors.iter().enumerate() {
|
||||||
|
if i == moving_idx {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ox = other.x;
|
||||||
|
let oy = other.y;
|
||||||
|
let ow = other.world_width() as i32;
|
||||||
|
let oh = other.world_height() as i32;
|
||||||
|
|
||||||
|
// X-axis edge pairs:
|
||||||
|
// moving left vs other left
|
||||||
|
let pairs_x = [
|
||||||
|
(new_x, ox), // left aligned
|
||||||
|
(new_x, ox + ow), // moving left snaps to other right
|
||||||
|
(new_x + mw, ox), // moving right snaps to other left
|
||||||
|
(new_x + mw, ox + ow), // right aligned
|
||||||
|
];
|
||||||
|
for (ma, oa) in pairs_x {
|
||||||
|
let dist = (ma - oa).abs();
|
||||||
|
if dist < best_x_dist {
|
||||||
|
best_x_dist = dist;
|
||||||
|
snap_x = new_x + (oa - ma);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y-axis edge pairs
|
||||||
|
let pairs_y = [
|
||||||
|
(new_y, oy),
|
||||||
|
(new_y, oy + oh),
|
||||||
|
(new_y + mh, oy),
|
||||||
|
(new_y + mh, oy + oh),
|
||||||
|
];
|
||||||
|
for (ma, oa) in pairs_y {
|
||||||
|
let dist = (ma - oa).abs();
|
||||||
|
if dist < best_y_dist {
|
||||||
|
best_y_dist = dist;
|
||||||
|
snap_y = new_y + (oa - ma);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(snap_x, snap_y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the selected monitor by (dx, dy) pixels, then snap.
|
||||||
|
pub fn move_selected(state: &LayoutState, monitors: &mut Vec<Monitor>, dx: i32, dy: i32) {
|
||||||
|
let idx = state.selected;
|
||||||
|
if idx >= monitors.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let new_x = monitors[idx].x + dx;
|
||||||
|
let new_y = monitors[idx].y + dy;
|
||||||
|
let (sx, sy) = snap_position(idx, new_x, new_y, monitors, state.snap_threshold);
|
||||||
|
monitors[idx].x = sx;
|
||||||
|
monitors[idx].y = sy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place monitors in a left-to-right row with no gaps.
|
||||||
|
pub fn auto_arrange(monitors: &mut Vec<Monitor>) {
|
||||||
|
let mut cursor = 0i32;
|
||||||
|
for m in monitors.iter_mut() {
|
||||||
|
m.x = cursor;
|
||||||
|
m.y = 0;
|
||||||
|
cursor += m.world_width() as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute canvas scale: returns pixels-per-cell such that all monitors fit in (area_w, area_h) cells.
|
||||||
|
/// The 0.5 factor corrects for terminal cells being ~2:1 tall.
|
||||||
|
pub fn canvas_scale(monitors: &[Monitor], area_w: u16, area_h: u16, zoom: f32) -> f32 {
|
||||||
|
if monitors.is_empty() || area_w == 0 || area_h == 0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let (min_x, min_y, max_x, max_y) = bounding_box(monitors);
|
||||||
|
let total_w = (max_x - min_x).max(1) as f32;
|
||||||
|
let total_h = (max_y - min_y).max(1) as f32;
|
||||||
|
|
||||||
|
let sx = area_w as f32 / total_w;
|
||||||
|
let sy = area_h as f32 / total_h * 0.5; // cell-aspect correction
|
||||||
|
sx.min(sy) * zoom
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (min_x, min_y, max_x, max_y) in world coordinates
|
||||||
|
pub fn bounding_box(monitors: &[Monitor]) -> (i32, i32, i32, i32) {
|
||||||
|
let min_x = monitors.iter().map(|m| m.x).min().unwrap_or(0);
|
||||||
|
let min_y = monitors.iter().map(|m| m.y).min().unwrap_or(0);
|
||||||
|
let max_x = monitors.iter().map(|m| m.right_edge()).max().unwrap_or(1);
|
||||||
|
let max_y = monitors.iter().map(|m| m.bottom_edge()).max().unwrap_or(1);
|
||||||
|
(min_x, min_y, max_x, max_y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a canvas cell position back to world coordinates (inverse of world_to_canvas)
|
||||||
|
pub fn canvas_to_world(
|
||||||
|
col: u16,
|
||||||
|
row: u16,
|
||||||
|
scale: f32,
|
||||||
|
origin_x: i32,
|
||||||
|
origin_y: i32,
|
||||||
|
pad_x: u16,
|
||||||
|
pad_y: u16,
|
||||||
|
) -> (i32, i32) {
|
||||||
|
let wx = if col >= pad_x {
|
||||||
|
((col - pad_x) as f32 / scale) as i32 + origin_x
|
||||||
|
} else {
|
||||||
|
origin_x - (((pad_x - col) as f32 / scale) as i32)
|
||||||
|
};
|
||||||
|
let wy = if row >= pad_y {
|
||||||
|
((row - pad_y) as f32 / (scale * 0.5)) as i32 + origin_y
|
||||||
|
} else {
|
||||||
|
origin_y - (((pad_y - row) as f32 / (scale * 0.5)) as i32)
|
||||||
|
};
|
||||||
|
(wx, wy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a world coordinate to a canvas cell position
|
||||||
|
pub fn world_to_canvas(
|
||||||
|
wx: i32,
|
||||||
|
wy: i32,
|
||||||
|
scale: f32,
|
||||||
|
origin_x: i32,
|
||||||
|
origin_y: i32,
|
||||||
|
pad_x: u16,
|
||||||
|
pad_y: u16,
|
||||||
|
) -> (u16, u16) {
|
||||||
|
let cx = ((wx - origin_x) as f32 * scale) as u16 + pad_x;
|
||||||
|
let cy = ((wy - origin_y) as f32 * scale * 0.5) as u16 + pad_y;
|
||||||
|
(cx, cy)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::monitor::{Mode, Transform};
|
||||||
|
|
||||||
|
fn mon(name: &str, w: u32, h: u32, x: i32, y: i32) -> Monitor {
|
||||||
|
Monitor {
|
||||||
|
name: name.into(),
|
||||||
|
description: String::new(),
|
||||||
|
active_mode: Mode { width: w, height: h, refresh: 60.0 },
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
scale: 1.0,
|
||||||
|
transform: Transform::Normal,
|
||||||
|
vrr: false,
|
||||||
|
dpms: true,
|
||||||
|
disabled: false,
|
||||||
|
mirror_of: None,
|
||||||
|
available_modes: vec![],
|
||||||
|
physical_width_mm: 0,
|
||||||
|
physical_height_mm: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snap_to_adjacent_right_edge() {
|
||||||
|
let monitors = vec![
|
||||||
|
mon("A", 1920, 1200, 0, 0),
|
||||||
|
mon("B", 1920, 1080, 1915, 0), // almost touching, 5px gap
|
||||||
|
];
|
||||||
|
// Moving B (index 1) at x=1915, A's right edge is 1920
|
||||||
|
// snap should pull B to x=1920
|
||||||
|
let (sx, _sy) = snap_position(1, 1915, 0, &monitors, 10);
|
||||||
|
assert_eq!(sx, 1920);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_snap_beyond_threshold() {
|
||||||
|
let monitors = vec![
|
||||||
|
mon("A", 1920, 1200, 0, 0),
|
||||||
|
mon("B", 1920, 1080, 1950, 0), // 30px gap, beyond threshold
|
||||||
|
];
|
||||||
|
let (sx, _sy) = snap_position(1, 1950, 0, &monitors, 10);
|
||||||
|
assert_eq!(sx, 1950);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_arrange_no_gaps() {
|
||||||
|
let mut monitors = vec![
|
||||||
|
mon("A", 1920, 1200, 100, 50),
|
||||||
|
mon("B", 1280, 1024, 200, 100),
|
||||||
|
];
|
||||||
|
auto_arrange(&mut monitors);
|
||||||
|
assert_eq!(monitors[0].x, 0);
|
||||||
|
assert_eq!(monitors[0].y, 0);
|
||||||
|
assert_eq!(monitors[1].x, 1920);
|
||||||
|
assert_eq!(monitors[1].y, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
src/main.rs
Normal file
208
src/main.rs
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
mod layout;
|
||||||
|
mod mirror;
|
||||||
|
mod monitor;
|
||||||
|
mod profile;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::{
|
||||||
|
event::{
|
||||||
|
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind,
|
||||||
|
MouseEventKind,
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use futures::StreamExt;
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
use tokio::{
|
||||||
|
sync::mpsc,
|
||||||
|
time::{interval, Duration},
|
||||||
|
};
|
||||||
|
|
||||||
|
use ui::{AppState, StatusLevel};
|
||||||
|
|
||||||
|
fn hyprland_socket2_path() -> Option<String> {
|
||||||
|
let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
||||||
|
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".into());
|
||||||
|
Some(format!("{}/hypr/{}/.socket2.sock", runtime, instance))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum AppEvent {
|
||||||
|
Key(crossterm::event::KeyEvent),
|
||||||
|
Mouse(crossterm::event::MouseEvent),
|
||||||
|
Resize(u16, u16),
|
||||||
|
Tick,
|
||||||
|
MonitorChange,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let monitors = monitor::load_monitors().await.unwrap_or_else(|e| {
|
||||||
|
eprintln!("Warning: could not load monitors: {}", e);
|
||||||
|
vec![]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Terminal setup
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let result = run(&mut terminal, monitors).await;
|
||||||
|
|
||||||
|
// Restore terminal
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
monitors: Vec<monitor::Monitor>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let size = terminal.size()?;
|
||||||
|
let mut state = AppState::new(monitors, (size.width, size.height));
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
|
||||||
|
|
||||||
|
// Event reader task
|
||||||
|
let tx_events = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut stream = EventStream::new();
|
||||||
|
let mut tick = interval(Duration::from_secs(5));
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tick.tick() => {
|
||||||
|
let _ = tx_events.send(AppEvent::Tick);
|
||||||
|
}
|
||||||
|
maybe_event = stream.next() => {
|
||||||
|
match maybe_event {
|
||||||
|
Some(Ok(Event::Key(key))) => {
|
||||||
|
let _ = tx_events.send(AppEvent::Key(key));
|
||||||
|
}
|
||||||
|
Some(Ok(Event::Mouse(mouse))) => {
|
||||||
|
let _ = tx_events.send(AppEvent::Mouse(mouse));
|
||||||
|
}
|
||||||
|
Some(Ok(Event::Resize(w, h))) => {
|
||||||
|
let _ = tx_events.send(AppEvent::Resize(w, h));
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hyprland socket hotplug listener
|
||||||
|
let tx_hotplug = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Some(sig) = hyprland_socket2_path() {
|
||||||
|
if let Ok(mut stream) = tokio::net::UnixStream::connect(&sig).await {
|
||||||
|
use tokio::io::AsyncBufReadExt;
|
||||||
|
let reader = tokio::io::BufReader::new(&mut stream);
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if line.starts_with("monitoradded")
|
||||||
|
|| line.starts_with("monitorremoved")
|
||||||
|
|| line.starts_with("monitorscale")
|
||||||
|
{
|
||||||
|
let _ = tx_hotplug.send(AppEvent::MonitorChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
state.tick_status();
|
||||||
|
terminal.draw(|f| ui::render(f, &state))?;
|
||||||
|
|
||||||
|
let event = rx.recv().await;
|
||||||
|
|
||||||
|
match event {
|
||||||
|
None => break,
|
||||||
|
Some(AppEvent::Tick) => {
|
||||||
|
if let Ok(monitors) = monitor::load_monitors().await {
|
||||||
|
if !state.dirty {
|
||||||
|
state.monitors = monitors;
|
||||||
|
state.layout.clamp_selected(state.monitors.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(AppEvent::MonitorChange) => {
|
||||||
|
if let Ok(monitors) = monitor::load_monitors().await {
|
||||||
|
state.monitors = monitors;
|
||||||
|
state.layout.clamp_selected(state.monitors.len());
|
||||||
|
state.set_status("Monitor configuration changed.", StatusLevel::Info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(AppEvent::Resize(w, h)) => {
|
||||||
|
state.terminal_size = (w, h);
|
||||||
|
}
|
||||||
|
Some(AppEvent::Mouse(mouse)) => {
|
||||||
|
if mouse.kind == MouseEventKind::Moved {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ui::handle_mouse(mouse, &mut state);
|
||||||
|
}
|
||||||
|
Some(AppEvent::Key(key)) => {
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
crossterm::event::KeyCode::Char('a') => {
|
||||||
|
state.pending_apply = true;
|
||||||
|
state.set_status("Applying...", StatusLevel::Info);
|
||||||
|
}
|
||||||
|
crossterm::event::KeyCode::Char('s') => {
|
||||||
|
ui::layout_view::trigger_save(&mut state);
|
||||||
|
}
|
||||||
|
crossterm::event::KeyCode::Char('r') => {
|
||||||
|
match monitor::load_monitors().await {
|
||||||
|
Ok(monitors) => {
|
||||||
|
state.monitors = monitors;
|
||||||
|
state.layout.clamp_selected(state.monitors.len());
|
||||||
|
state.dirty = false;
|
||||||
|
state.set_status("Monitors refreshed.", StatusLevel::Success);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.set_status(
|
||||||
|
format!("Refresh failed: {}", e),
|
||||||
|
StatusLevel::Error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if !ui::handle_key(key, &mut state) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.pending_apply {
|
||||||
|
state.pending_apply = false;
|
||||||
|
match monitor::apply_monitors(&state.monitors).await {
|
||||||
|
Ok(()) => {
|
||||||
|
state.set_status("Applied.", StatusLevel::Success);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.set_status(format!("Apply failed: {}", e), StatusLevel::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
299
src/mirror.rs
Normal file
299
src/mirror.rs
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::monitor::{Mode, Monitor};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MirrorResult {
|
||||||
|
pub source_mode: Mode,
|
||||||
|
pub mirror_mode: Mode,
|
||||||
|
pub refresh: f64,
|
||||||
|
pub ar_exact: bool,
|
||||||
|
pub ar_ratio: (u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gcd(a: u32, b: u32) -> u32 {
|
||||||
|
if b == 0 { a } else { gcd(b, a % b) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reduced_ar(w: u32, h: u32) -> (u32, u32) {
|
||||||
|
let g = gcd(w, h);
|
||||||
|
(w / g, h / g)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ratio_f64(ar: (u32, u32)) -> f64 {
|
||||||
|
ar.0 as f64 / ar.1 as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorResult> {
|
||||||
|
let src_modes = &source.available_modes;
|
||||||
|
let tgt_modes = &target.available_modes;
|
||||||
|
|
||||||
|
if src_modes.is_empty() || tgt_modes.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group source modes by reduced AR
|
||||||
|
let mut src_by_ar: HashMap<(u32, u32), Vec<&Mode>> = HashMap::new();
|
||||||
|
for m in src_modes {
|
||||||
|
src_by_ar.entry(reduced_ar(m.width, m.height)).or_default().push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Candidate {
|
||||||
|
src_ar: (u32, u32),
|
||||||
|
tgt_ar: (u32, u32),
|
||||||
|
is_exact: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates: Vec<Candidate> = Vec::new();
|
||||||
|
|
||||||
|
for t in tgt_modes {
|
||||||
|
let tgt_ar = reduced_ar(t.width, t.height);
|
||||||
|
let tgt_ratio = ratio_f64(tgt_ar);
|
||||||
|
|
||||||
|
if src_by_ar.contains_key(&tgt_ar) {
|
||||||
|
// Check if we already have this exact pair
|
||||||
|
if !candidates.iter().any(|c| c.src_ar == tgt_ar && c.tgt_ar == tgt_ar && c.is_exact) {
|
||||||
|
candidates.push(Candidate { src_ar: tgt_ar, tgt_ar, is_exact: true });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate: check all source ARs within 5%
|
||||||
|
for &s_ar in src_by_ar.keys() {
|
||||||
|
let s_ratio = ratio_f64(s_ar);
|
||||||
|
if (s_ratio - tgt_ratio).abs() / s_ratio < 0.05 {
|
||||||
|
if !candidates.iter().any(|c| c.src_ar == s_ar && c.tgt_ar == tgt_ar) {
|
||||||
|
candidates.push(Candidate { src_ar: s_ar, tgt_ar, is_exact: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score each candidate: sum of best pixel counts on each side
|
||||||
|
// Exact beats approximate regardless of score
|
||||||
|
let mut best_score: u64 = 0;
|
||||||
|
let mut best_exact = false;
|
||||||
|
let mut best_src_ar = (0u32, 0u32);
|
||||||
|
let mut best_tgt_ar = (0u32, 0u32);
|
||||||
|
|
||||||
|
for c in &candidates {
|
||||||
|
let src_max_px = src_by_ar[&c.src_ar]
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.pixels())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let tgt_max_px = tgt_modes
|
||||||
|
.iter()
|
||||||
|
.filter(|m| reduced_ar(m.width, m.height) == c.tgt_ar)
|
||||||
|
.map(|m| m.pixels())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let score = src_max_px + tgt_max_px;
|
||||||
|
|
||||||
|
let better = if c.is_exact && !best_exact {
|
||||||
|
true
|
||||||
|
} else if !c.is_exact && best_exact {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
score > best_score
|
||||||
|
};
|
||||||
|
|
||||||
|
if better {
|
||||||
|
best_score = score;
|
||||||
|
best_exact = c.is_exact;
|
||||||
|
best_src_ar = c.src_ar;
|
||||||
|
best_tgt_ar = c.tgt_ar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_src_ar == (0, 0) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best source mode (highest pixels at winning AR)
|
||||||
|
let best_src_mode = src_by_ar[&best_src_ar]
|
||||||
|
.iter()
|
||||||
|
.max_by_key(|m| m.pixels())
|
||||||
|
.copied()?;
|
||||||
|
|
||||||
|
// Best target mode
|
||||||
|
let best_tgt_mode = tgt_modes
|
||||||
|
.iter()
|
||||||
|
.filter(|m| reduced_ar(m.width, m.height) == best_tgt_ar)
|
||||||
|
.max_by_key(|m| m.pixels())?;
|
||||||
|
|
||||||
|
// Refresh matching
|
||||||
|
let src_refreshes: Vec<f64> = src_by_ar[&best_src_ar]
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.width == best_src_mode.width && m.height == best_src_mode.height)
|
||||||
|
.map(|m| m.refresh)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let tgt_refreshes: Vec<f64> = tgt_modes
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.width == best_tgt_mode.width && m.height == best_tgt_mode.height)
|
||||||
|
.map(|m| m.refresh)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Exact common (within 0.01 Hz)
|
||||||
|
let exact_common: Vec<f64> = src_refreshes
|
||||||
|
.iter()
|
||||||
|
.filter(|&&sr| tgt_refreshes.iter().any(|&tr| (sr - tr).abs() < 0.01))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let chosen_refresh = if !exact_common.is_empty() {
|
||||||
|
exact_common.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||||
|
} else {
|
||||||
|
// Near-match within 1 Hz
|
||||||
|
let near: Vec<f64> = src_refreshes
|
||||||
|
.iter()
|
||||||
|
.flat_map(|&sr| {
|
||||||
|
tgt_refreshes
|
||||||
|
.iter()
|
||||||
|
.filter(move |&&tr| (sr - tr).abs() <= 1.0)
|
||||||
|
.map(move |&tr| sr.min(tr))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !near.is_empty() {
|
||||||
|
near.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||||
|
} else {
|
||||||
|
// Fallback: max source refresh
|
||||||
|
src_refreshes.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find actual mode structs within 0.1 Hz of chosen_refresh
|
||||||
|
let final_src = src_by_ar[&best_src_ar]
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.width == best_src_mode.width && m.height == best_src_mode.height)
|
||||||
|
.filter(|m| (m.refresh - chosen_refresh).abs() < 0.1)
|
||||||
|
.max_by_key(|m| m.pixels())
|
||||||
|
.copied()
|
||||||
|
.or(Some(best_src_mode))?;
|
||||||
|
|
||||||
|
let final_tgt = tgt_modes
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.width == best_tgt_mode.width && m.height == best_tgt_mode.height)
|
||||||
|
.filter(|m| (m.refresh - chosen_refresh).abs() < 0.1)
|
||||||
|
.max_by_key(|m| m.pixels())
|
||||||
|
.or_else(|| {
|
||||||
|
// fallback: nearest refresh on target
|
||||||
|
tgt_modes
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.width == best_tgt_mode.width && m.height == best_tgt_mode.height)
|
||||||
|
.min_by(|a, b| {
|
||||||
|
let da = (a.refresh - chosen_refresh).abs();
|
||||||
|
let db = (b.refresh - chosen_refresh).abs();
|
||||||
|
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Some(MirrorResult {
|
||||||
|
source_mode: final_src.clone(),
|
||||||
|
mirror_mode: final_tgt.clone(),
|
||||||
|
refresh: chosen_refresh,
|
||||||
|
ar_exact: best_exact,
|
||||||
|
ar_ratio: best_src_ar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_match_label(result: &MirrorResult) -> &'static str {
|
||||||
|
let diff = (result.source_mode.refresh - result.mirror_mode.refresh).abs();
|
||||||
|
if diff < 0.01 {
|
||||||
|
"exact match"
|
||||||
|
} else if diff <= 1.0 {
|
||||||
|
"near match (≤1 Hz)"
|
||||||
|
} else {
|
||||||
|
"mismatch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::monitor::{Transform};
|
||||||
|
|
||||||
|
fn make_monitor_with_modes(name: &str, modes: Vec<Mode>) -> Monitor {
|
||||||
|
let active = modes[0].clone();
|
||||||
|
Monitor {
|
||||||
|
name: name.into(),
|
||||||
|
description: String::new(),
|
||||||
|
active_mode: active,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1.0,
|
||||||
|
transform: Transform::Normal,
|
||||||
|
vrr: false,
|
||||||
|
dpms: true,
|
||||||
|
disabled: false,
|
||||||
|
mirror_of: None,
|
||||||
|
available_modes: modes,
|
||||||
|
physical_width_mm: 0,
|
||||||
|
physical_height_mm: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn m(w: u32, h: u32, r: f64) -> Mode {
|
||||||
|
Mode { width: w, height: h, refresh: r }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_mode_lists_exact_match() {
|
||||||
|
let modes = vec![m(1920, 1200, 60.0), m(1280, 800, 60.0)];
|
||||||
|
let src = make_monitor_with_modes("eDP-1", modes.clone());
|
||||||
|
let tgt = make_monitor_with_modes("HDMI-A-1", modes);
|
||||||
|
let result = find_mirror_modes(&src, &tgt).unwrap();
|
||||||
|
assert!(result.ar_exact);
|
||||||
|
assert_eq!(result.source_mode.width, 1920);
|
||||||
|
assert_eq!(result.mirror_mode.width, 1920);
|
||||||
|
assert!((result.refresh - 60.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_ar_approx_match() {
|
||||||
|
// 16:9 source vs 16:10 target — AR ratio ~11% difference, exceeds 5%
|
||||||
|
// so this should fail to find a match (incompatible)
|
||||||
|
// Actually 16:10 ratio is 1.6, 16:9 is 1.777, diff/1.777 = 10% > 5%
|
||||||
|
let src = make_monitor_with_modes("src", vec![m(1920, 1080, 60.0)]);
|
||||||
|
let tgt = make_monitor_with_modes("tgt", vec![m(1920, 1200, 60.0)]);
|
||||||
|
// These are >5% apart so should be None
|
||||||
|
let result = find_mirror_modes(&src, &tgt);
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn near_common_ar_within_5pct() {
|
||||||
|
// 16:9 = 1.7778, 17:9 = 1.8889, diff/1.7778 = ~6%, just over
|
||||||
|
// Let's use a case within 5%: 1920x1080 (16:9 = 1.7778) vs 2560x1440 (16:9 = 1.7778)
|
||||||
|
let src = make_monitor_with_modes("src", vec![m(1920, 1080, 60.0)]);
|
||||||
|
let tgt = make_monitor_with_modes("tgt", vec![m(2560, 1440, 60.0)]);
|
||||||
|
let result = find_mirror_modes(&src, &tgt).unwrap();
|
||||||
|
assert!(result.ar_exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn near_refresh_match() {
|
||||||
|
let src = make_monitor_with_modes("src", vec![m(1920, 1080, 60.0)]);
|
||||||
|
let tgt = make_monitor_with_modes("tgt", vec![m(1920, 1080, 59.94)]);
|
||||||
|
let result = find_mirror_modes(&src, &tgt).unwrap();
|
||||||
|
assert!(result.ar_exact);
|
||||||
|
// chosen_refresh should be the min of the near pair = 59.94
|
||||||
|
assert!((result.refresh - 59.94).abs() < 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incompatible_modes_returns_none() {
|
||||||
|
let src = make_monitor_with_modes("src", vec![m(1920, 1080, 60.0)]);
|
||||||
|
let tgt = make_monitor_with_modes("tgt", vec![m(1024, 768, 60.0)]);
|
||||||
|
// 16:9 vs 4:3, difference >> 5%
|
||||||
|
let result = find_mirror_modes(&src, &tgt);
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
461
src/monitor.rs
Normal file
461
src/monitor.rs
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RawMonitor {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub scale: f64,
|
||||||
|
pub refresh_rate: f64,
|
||||||
|
pub transform: u8,
|
||||||
|
pub available_modes: Vec<String>,
|
||||||
|
pub physical_width: u32,
|
||||||
|
pub physical_height: u32,
|
||||||
|
pub dpms_status: bool,
|
||||||
|
pub vrr: bool,
|
||||||
|
pub mirror_of: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Mode {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub refresh: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mode {
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
// Format: "1920x1200@60.00Hz"
|
||||||
|
let s = s.trim_end_matches("Hz");
|
||||||
|
let (res, refresh_str) = s.split_once('@')?;
|
||||||
|
let (w_str, h_str) = res.split_once('x')?;
|
||||||
|
Some(Mode {
|
||||||
|
width: w_str.parse().ok()?,
|
||||||
|
height: h_str.parse().ok()?,
|
||||||
|
refresh: refresh_str.parse().ok()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compact(&self) -> String {
|
||||||
|
format!("{}x{}@{:.2}", self.width, self.height, self.refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pixels(&self) -> u64 {
|
||||||
|
self.width as u64 * self.height as u64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Mode {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}x{}@{:.2}Hz", self.width, self.height, self.refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Transform {
|
||||||
|
Normal = 0,
|
||||||
|
R90 = 1,
|
||||||
|
R180 = 2,
|
||||||
|
R270 = 3,
|
||||||
|
Flipped = 4,
|
||||||
|
FlippedR90 = 5,
|
||||||
|
FlippedR180 = 6,
|
||||||
|
FlippedR270 = 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transform {
|
||||||
|
pub fn from_u8(v: u8) -> Self {
|
||||||
|
match v {
|
||||||
|
1 => Self::R90,
|
||||||
|
2 => Self::R180,
|
||||||
|
3 => Self::R270,
|
||||||
|
4 => Self::Flipped,
|
||||||
|
5 => Self::FlippedR90,
|
||||||
|
6 => Self::FlippedR180,
|
||||||
|
7 => Self::FlippedR270,
|
||||||
|
_ => Self::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_u8(self) -> u8 {
|
||||||
|
self as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Normal => "Normal",
|
||||||
|
Self::R90 => "90°",
|
||||||
|
Self::R180 => "180°",
|
||||||
|
Self::R270 => "270°",
|
||||||
|
Self::Flipped => "Flipped",
|
||||||
|
Self::FlippedR90 => "Flipped 90°",
|
||||||
|
Self::FlippedR180 => "Flipped 180°",
|
||||||
|
Self::FlippedR270 => "Flipped 270°",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all() -> &'static [Transform] {
|
||||||
|
&[
|
||||||
|
Self::Normal,
|
||||||
|
Self::R90,
|
||||||
|
Self::R180,
|
||||||
|
Self::R270,
|
||||||
|
Self::Flipped,
|
||||||
|
Self::FlippedR90,
|
||||||
|
Self::FlippedR180,
|
||||||
|
Self::FlippedR270,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Monitor {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub active_mode: Mode,
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub scale: f64,
|
||||||
|
pub transform: Transform,
|
||||||
|
pub vrr: bool,
|
||||||
|
pub dpms: bool,
|
||||||
|
pub disabled: bool,
|
||||||
|
pub mirror_of: Option<String>,
|
||||||
|
pub available_modes: Vec<Mode>,
|
||||||
|
/// Physical dimensions in millimetres (0 if unknown)
|
||||||
|
pub physical_width_mm: u32,
|
||||||
|
pub physical_height_mm: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Monitor {
|
||||||
|
fn from_raw(raw: RawMonitor) -> Self {
|
||||||
|
let mut modes: Vec<Mode> = raw
|
||||||
|
.available_modes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| Mode::parse(s))
|
||||||
|
.collect();
|
||||||
|
// Sort descending by pixels then refresh for consistent ordering
|
||||||
|
modes.sort_by(|a, b| {
|
||||||
|
b.pixels()
|
||||||
|
.cmp(&a.pixels())
|
||||||
|
.then(b.refresh.partial_cmp(&a.refresh).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
});
|
||||||
|
modes.dedup_by(|a, b| a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01);
|
||||||
|
|
||||||
|
let active_mode = Mode {
|
||||||
|
width: raw.width,
|
||||||
|
height: raw.height,
|
||||||
|
refresh: raw.refresh_rate,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mirror_of = if raw.mirror_of.is_empty() || raw.mirror_of == "none" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(raw.mirror_of)
|
||||||
|
};
|
||||||
|
|
||||||
|
Monitor {
|
||||||
|
name: raw.name,
|
||||||
|
description: raw.description,
|
||||||
|
active_mode,
|
||||||
|
x: raw.x,
|
||||||
|
y: raw.y,
|
||||||
|
scale: raw.scale,
|
||||||
|
transform: Transform::from_u8(raw.transform),
|
||||||
|
vrr: raw.vrr,
|
||||||
|
dpms: raw.dpms_status,
|
||||||
|
disabled: raw.disabled,
|
||||||
|
mirror_of,
|
||||||
|
available_modes: modes,
|
||||||
|
physical_width_mm: raw.physical_width,
|
||||||
|
physical_height_mm: raw.physical_height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Width in world coordinates (swapped for 90/270 transforms)
|
||||||
|
pub fn world_width(&self) -> u32 {
|
||||||
|
match self.transform {
|
||||||
|
Transform::R90 | Transform::R270 | Transform::FlippedR90 | Transform::FlippedR270 => {
|
||||||
|
self.active_mode.height
|
||||||
|
}
|
||||||
|
_ => self.active_mode.width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Height in world coordinates
|
||||||
|
pub fn world_height(&self) -> u32 {
|
||||||
|
match self.transform {
|
||||||
|
Transform::R90 | Transform::R270 | Transform::FlippedR90 | Transform::FlippedR270 => {
|
||||||
|
self.active_mode.width
|
||||||
|
}
|
||||||
|
_ => self.active_mode.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn right_edge(&self) -> i32 {
|
||||||
|
self.x + self.world_width() as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bottom_edge(&self) -> i32 {
|
||||||
|
self.y + self.world_height() as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixels-per-inch, or None if physical dimensions are unknown.
|
||||||
|
pub fn ppi(&self) -> Option<f64> {
|
||||||
|
if self.physical_width_mm == 0 || self.physical_height_mm == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let diag_px = ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt();
|
||||||
|
let diag_mm = ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt();
|
||||||
|
Some(diag_px / (diag_mm / 25.4))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suggested fractional scale based on PPI.
|
||||||
|
pub fn suggested_scale(&self) -> Option<f64> {
|
||||||
|
let ppi = self.ppi()?;
|
||||||
|
Some(if ppi < 110.0 {
|
||||||
|
1.0
|
||||||
|
} else if ppi < 150.0 {
|
||||||
|
1.25
|
||||||
|
} else if ppi < 200.0 {
|
||||||
|
1.5
|
||||||
|
} else {
|
||||||
|
2.0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unique resolutions available (deduped WxH pairs)
|
||||||
|
pub fn unique_resolutions(&self) -> Vec<(u32, u32)> {
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for m in &self.available_modes {
|
||||||
|
let pair = (m.width, m.height);
|
||||||
|
if !seen.contains(&pair) {
|
||||||
|
seen.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh rates available for a given WxH
|
||||||
|
pub fn refreshes_for(&self, width: u32, height: u32) -> Vec<f64> {
|
||||||
|
self.available_modes
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.width == width && m.height == height)
|
||||||
|
.map(|m| m.refresh)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_monitors() -> Result<Vec<Monitor>> {
|
||||||
|
let output = Command::new("hyprctl")
|
||||||
|
.args(["monitors", "all", "-j"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("failed to run hyprctl monitors all -j")?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let raw: Vec<RawMonitor> =
|
||||||
|
serde_json::from_str(&stdout).context("failed to parse hyprctl monitors JSON")?;
|
||||||
|
|
||||||
|
// Hyprland reports mirrorOf as a numeric ID string when using `monitors all`.
|
||||||
|
// Resolve to monitor name so format_hypr_line emits the correct `mirror,<name>`.
|
||||||
|
let id_to_name: std::collections::HashMap<String, String> =
|
||||||
|
raw.iter().map(|r| (r.id.to_string(), r.name.clone())).collect();
|
||||||
|
|
||||||
|
Ok(raw
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut r| {
|
||||||
|
if r.mirror_of != "none" && !r.mirror_of.is_empty() {
|
||||||
|
if let Some(name) = id_to_name.get(&r.mirror_of) {
|
||||||
|
r.mirror_of = name.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Monitor::from_raw(r)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_hypr_line(m: &Monitor) -> String {
|
||||||
|
if let Some(src) = &m.mirror_of {
|
||||||
|
format!(
|
||||||
|
"monitor={},{},auto,{:.2},mirror,{}",
|
||||||
|
m.name,
|
||||||
|
m.active_mode.compact(),
|
||||||
|
m.scale,
|
||||||
|
src
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"monitor={},{},{}x{},{:.2},transform,{},vrr,{}",
|
||||||
|
m.name,
|
||||||
|
m.active_mode.compact(),
|
||||||
|
m.x,
|
||||||
|
m.y,
|
||||||
|
m.scale,
|
||||||
|
m.transform.as_u8(),
|
||||||
|
if m.vrr { 1 } else { 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn apply_monitors(monitors: &[Monitor]) -> Result<()> {
|
||||||
|
// breadmon stores mirror_of on the slave; hl.monitor() wants mirror= on the source.
|
||||||
|
let mut mirror_slaves: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
|
||||||
|
for m in monitors {
|
||||||
|
if let Some(src) = &m.mirror_of {
|
||||||
|
mirror_slaves.insert(src.as_str(), m.name.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut eval_stmts: Vec<String> = Vec::new();
|
||||||
|
let mut dpms_off: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for m in monitors {
|
||||||
|
if m.disabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if m.mirror_of.is_some() {
|
||||||
|
// Mirror slaves get no independent hl.monitor() call; only track DPMS.
|
||||||
|
if !m.dpms {
|
||||||
|
dpms_off.push(m.name.clone());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = format!(
|
||||||
|
"{}x{}@{}",
|
||||||
|
m.active_mode.width,
|
||||||
|
m.active_mode.height,
|
||||||
|
(m.active_mode.refresh + 0.5) as u32
|
||||||
|
);
|
||||||
|
let position = format!("{}x{}", m.x, m.y);
|
||||||
|
let scale = format!("{:.2}", m.scale);
|
||||||
|
|
||||||
|
let mut stmt = format!(
|
||||||
|
"hl.monitor({{ output = \"{}\", mode = \"{}\", position = \"{}\", scale = \"{}\", transform = {}, vrr = {}",
|
||||||
|
m.name, mode, position, scale, m.transform.as_u8(), m.vrr
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(&slave) = mirror_slaves.get(m.name.as_str()) {
|
||||||
|
stmt.push_str(&format!(", mirror = \"{}\"", slave));
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt.push_str(" })");
|
||||||
|
eval_stmts.push(stmt);
|
||||||
|
|
||||||
|
if !m.dpms {
|
||||||
|
dpms_off.push(m.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !eval_stmts.is_empty() {
|
||||||
|
let lua = eval_stmts.join("; ");
|
||||||
|
let output = Command::new("hyprctl")
|
||||||
|
.args(["eval", &lua])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("hyprctl eval failed")?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||||
|
if stdout != "ok" {
|
||||||
|
return Err(anyhow::anyhow!("hyprctl: {}", stdout));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in dpms_off {
|
||||||
|
Command::new("hyprctl")
|
||||||
|
.args(["dispatch", "dpms", "off", &name])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("hyprctl dispatch dpms off failed")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_parse_normal() {
|
||||||
|
let m = Mode::parse("1920x1200@60.00Hz").unwrap();
|
||||||
|
assert_eq!(m.width, 1920);
|
||||||
|
assert_eq!(m.height, 1200);
|
||||||
|
assert!((m.refresh - 60.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_parse_fractional() {
|
||||||
|
let m = Mode::parse("3840x2160@59.94Hz").unwrap();
|
||||||
|
assert_eq!(m.width, 3840);
|
||||||
|
assert_eq!(m.height, 2160);
|
||||||
|
assert!((m.refresh - 59.94).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_compact_roundtrip() {
|
||||||
|
let m = Mode { width: 1920, height: 1080, refresh: 60.0 };
|
||||||
|
let s = m.compact();
|
||||||
|
let m2 = Mode::parse(&format!("{}Hz", s)).unwrap();
|
||||||
|
assert_eq!(m.width, m2.width);
|
||||||
|
assert_eq!(m.height, m2.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_hypr_line_normal() {
|
||||||
|
let m = Monitor {
|
||||||
|
name: "eDP-1".into(),
|
||||||
|
description: String::new(),
|
||||||
|
active_mode: Mode { width: 1920, height: 1200, refresh: 60.0 },
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1.0,
|
||||||
|
transform: Transform::Normal,
|
||||||
|
vrr: false,
|
||||||
|
dpms: true,
|
||||||
|
disabled: false,
|
||||||
|
mirror_of: None,
|
||||||
|
available_modes: vec![],
|
||||||
|
physical_width_mm: 0,
|
||||||
|
physical_height_mm: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
format_hypr_line(&m),
|
||||||
|
"monitor=eDP-1,1920x1200@60.00,0x0,1.00,transform,0,vrr,0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_hypr_line_mirror() {
|
||||||
|
let m = Monitor {
|
||||||
|
name: "HDMI-A-1".into(),
|
||||||
|
description: String::new(),
|
||||||
|
active_mode: Mode { width: 1920, height: 1080, refresh: 60.0 },
|
||||||
|
x: 1920,
|
||||||
|
y: 0,
|
||||||
|
scale: 1.0,
|
||||||
|
transform: Transform::Normal,
|
||||||
|
vrr: false,
|
||||||
|
dpms: true,
|
||||||
|
disabled: false,
|
||||||
|
mirror_of: Some("eDP-1".into()),
|
||||||
|
available_modes: vec![],
|
||||||
|
physical_width_mm: 0,
|
||||||
|
physical_height_mm: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
format_hypr_line(&m),
|
||||||
|
"monitor=HDMI-A-1,1920x1080@60.00,auto,1.00,mirror,eDP-1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/profile.rs
Normal file
179
src/profile.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::monitor::{Mode, Monitor, Transform};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileMonitor {
|
||||||
|
pub name: String,
|
||||||
|
pub mode: String,
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub scale: f64,
|
||||||
|
pub transform: u8,
|
||||||
|
pub vrr: bool,
|
||||||
|
pub dpms: bool,
|
||||||
|
pub mirror_of: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileMeta {
|
||||||
|
pub name: String,
|
||||||
|
pub created: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Profile {
|
||||||
|
pub profile: ProfileMeta,
|
||||||
|
pub monitors: Vec<ProfileMonitor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn profiles_dir() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||||
|
.join("breadmon/profiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(profile: &Profile) -> Result<()> {
|
||||||
|
let dir = profiles_dir();
|
||||||
|
std::fs::create_dir_all(&dir).context("failed to create profiles dir")?;
|
||||||
|
let path = dir.join(format!("{}.toml", profile.profile.name));
|
||||||
|
let content = toml::to_string_pretty(profile).context("failed to serialize profile")?;
|
||||||
|
std::fs::write(&path, content).context("failed to write profile")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(name: &str) -> Result<Profile> {
|
||||||
|
let path = profiles_dir().join(format!("{}.toml", name));
|
||||||
|
let content = std::fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("failed to read profile '{}'", name))?;
|
||||||
|
toml::from_str(&content).context("failed to parse profile TOML")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list() -> Result<Vec<String>> {
|
||||||
|
let dir = profiles_dir();
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(&dir).context("failed to read profiles dir")? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
|
||||||
|
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||||
|
names.push(stem.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort();
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(name: &str) -> Result<()> {
|
||||||
|
let path = profiles_dir().join(format!("{}.toml", name));
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
.with_context(|| format!("failed to delete profile '{}'", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile {
|
||||||
|
let profile_monitors = monitors
|
||||||
|
.iter()
|
||||||
|
.map(|m| ProfileMonitor {
|
||||||
|
name: m.name.clone(),
|
||||||
|
mode: m.active_mode.compact(),
|
||||||
|
x: m.x,
|
||||||
|
y: m.y,
|
||||||
|
scale: m.scale,
|
||||||
|
transform: m.transform.as_u8(),
|
||||||
|
vrr: m.vrr,
|
||||||
|
dpms: m.dpms,
|
||||||
|
mirror_of: m.mirror_of.clone().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Profile {
|
||||||
|
profile: ProfileMeta {
|
||||||
|
name: name.to_owned(),
|
||||||
|
created: chrono_now(),
|
||||||
|
},
|
||||||
|
monitors: profile_monitors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a profile's settings onto a list of live monitors (matched by name).
|
||||||
|
/// Monitors not in the profile are left unchanged.
|
||||||
|
pub fn apply_to_monitors(profile: &Profile, monitors: &mut Vec<Monitor>) {
|
||||||
|
for pm in &profile.monitors {
|
||||||
|
if let Some(m) = monitors.iter_mut().find(|m| m.name == pm.name) {
|
||||||
|
if let Some(mode) = Mode::parse(&format!("{}Hz", pm.mode)) {
|
||||||
|
m.active_mode = mode;
|
||||||
|
}
|
||||||
|
m.x = pm.x;
|
||||||
|
m.y = pm.y;
|
||||||
|
m.scale = pm.scale;
|
||||||
|
m.transform = Transform::from_u8(pm.transform);
|
||||||
|
m.vrr = pm.vrr;
|
||||||
|
m.dpms = pm.dpms;
|
||||||
|
m.mirror_of = if pm.mirror_of.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(pm.mirror_of.clone())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chrono_now() -> String {
|
||||||
|
// Simple ISO 8601 timestamp without pulling in chrono
|
||||||
|
// Uses date command; falls back to a placeholder if unavailable
|
||||||
|
std::process::Command::new("date")
|
||||||
|
.arg("+%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_owned())
|
||||||
|
.unwrap_or_else(|| "unknown".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::monitor::{Mode, Transform};
|
||||||
|
|
||||||
|
fn make_monitor(name: &str, w: u32, h: u32, x: i32, y: i32) -> Monitor {
|
||||||
|
Monitor {
|
||||||
|
name: name.into(),
|
||||||
|
description: String::new(),
|
||||||
|
active_mode: Mode { width: w, height: h, refresh: 60.0 },
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
scale: 1.0,
|
||||||
|
transform: Transform::Normal,
|
||||||
|
vrr: false,
|
||||||
|
dpms: true,
|
||||||
|
disabled: false,
|
||||||
|
mirror_of: None,
|
||||||
|
available_modes: vec![],
|
||||||
|
physical_width_mm: 0,
|
||||||
|
physical_height_mm: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_roundtrip() {
|
||||||
|
let monitors = vec![
|
||||||
|
make_monitor("eDP-1", 1920, 1200, 0, 0),
|
||||||
|
make_monitor("HDMI-A-1", 1920, 1080, 1920, 0),
|
||||||
|
];
|
||||||
|
let profile = from_monitors("test", &monitors);
|
||||||
|
let serialized = toml::to_string_pretty(&profile).unwrap();
|
||||||
|
let deserialized: Profile = toml::from_str(&serialized).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(deserialized.profile.name, "test");
|
||||||
|
assert_eq!(deserialized.monitors.len(), 2);
|
||||||
|
assert_eq!(deserialized.monitors[0].name, "eDP-1");
|
||||||
|
assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00");
|
||||||
|
assert_eq!(deserialized.monitors[1].x, 1920);
|
||||||
|
}
|
||||||
|
}
|
||||||
488
src/ui/config_view.rs
Normal file
488
src/ui/config_view.rs
Normal file
|
|
@ -0,0 +1,488 @@
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
monitor::{Monitor, Transform},
|
||||||
|
ui::{AppState, StatusLevel},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ConfigField {
|
||||||
|
Resolution,
|
||||||
|
Refresh,
|
||||||
|
Scale,
|
||||||
|
Transform,
|
||||||
|
Vrr,
|
||||||
|
Dpms,
|
||||||
|
MirrorOf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigField {
|
||||||
|
const ALL: &'static [ConfigField] = &[
|
||||||
|
Self::Resolution,
|
||||||
|
Self::Refresh,
|
||||||
|
Self::Scale,
|
||||||
|
Self::Transform,
|
||||||
|
Self::Vrr,
|
||||||
|
Self::Dpms,
|
||||||
|
Self::MirrorOf,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Resolution => "Resolution",
|
||||||
|
Self::Refresh => "Refresh",
|
||||||
|
Self::Scale => "Scale",
|
||||||
|
Self::Transform => "Transform",
|
||||||
|
Self::Vrr => "VRR",
|
||||||
|
Self::Dpms => "DPMS",
|
||||||
|
Self::MirrorOf => "Mirror of",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ConfigState {
|
||||||
|
pub monitor_idx: usize,
|
||||||
|
pub focused: usize, // index into ConfigField::ALL
|
||||||
|
// Dropdown indices
|
||||||
|
pub res_idx: usize,
|
||||||
|
pub refresh_idx: usize,
|
||||||
|
pub transform_idx: usize,
|
||||||
|
pub mirror_idx: usize, // 0 = none, 1.. = monitor names
|
||||||
|
// Scale editing
|
||||||
|
pub scale_str: String,
|
||||||
|
pub scale_editing: bool,
|
||||||
|
// Cached lists
|
||||||
|
pub resolutions: Vec<(u32, u32)>,
|
||||||
|
pub refreshes: Vec<f64>,
|
||||||
|
pub mirror_options: Vec<String>, // "(none)", then monitor names
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigState {
|
||||||
|
pub fn sync_from_monitor(&mut self, idx: usize, monitors: &[Monitor]) {
|
||||||
|
if monitors.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let idx = idx.min(monitors.len() - 1);
|
||||||
|
self.monitor_idx = idx;
|
||||||
|
let m = &monitors[idx];
|
||||||
|
|
||||||
|
self.resolutions = m.unique_resolutions();
|
||||||
|
// Find current resolution index
|
||||||
|
self.res_idx = self
|
||||||
|
.resolutions
|
||||||
|
.iter()
|
||||||
|
.position(|&(w, h)| w == m.active_mode.width && h == m.active_mode.height)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
self.update_refreshes(m);
|
||||||
|
|
||||||
|
self.transform_idx = m.transform.as_u8() as usize;
|
||||||
|
self.scale_str = format!("{:.2}", m.scale);
|
||||||
|
self.scale_editing = false;
|
||||||
|
|
||||||
|
// Mirror options
|
||||||
|
self.mirror_options = std::iter::once("(none)".to_owned())
|
||||||
|
.chain(
|
||||||
|
monitors
|
||||||
|
.iter()
|
||||||
|
.filter(|other| other.name != m.name)
|
||||||
|
.map(|other| other.name.clone()),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.mirror_idx = m
|
||||||
|
.mirror_of
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|src| self.mirror_options.iter().position(|o| o == src))
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_refreshes(&mut self, m: &Monitor) {
|
||||||
|
if let Some(&(w, h)) = self.resolutions.get(self.res_idx) {
|
||||||
|
self.refreshes = m.refreshes_for(w, h);
|
||||||
|
self.refresh_idx = self
|
||||||
|
.refreshes
|
||||||
|
.iter()
|
||||||
|
.position(|&r| (r - m.active_mode.refresh).abs() < 0.01)
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_field(&self) -> ConfigField {
|
||||||
|
ConfigField::ALL[self.focused.min(ConfigField::ALL.len() - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_field(&mut self) {
|
||||||
|
self.focused = (self.focused + 1) % ConfigField::ALL.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev_field(&mut self) {
|
||||||
|
self.focused = self.focused.checked_sub(1).unwrap_or(ConfigField::ALL.len() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
|
let cfg = &mut state.config;
|
||||||
|
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
|
cfg.scale_editing = false;
|
||||||
|
cfg.next_field();
|
||||||
|
}
|
||||||
|
KeyCode::Char('k') | KeyCode::Up => {
|
||||||
|
cfg.scale_editing = false;
|
||||||
|
cfg.prev_field();
|
||||||
|
}
|
||||||
|
KeyCode::Tab => {
|
||||||
|
cfg.scale_editing = false;
|
||||||
|
cfg.next_field();
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
cfg.scale_editing = false;
|
||||||
|
cfg.prev_field();
|
||||||
|
}
|
||||||
|
// Navigate between monitors
|
||||||
|
KeyCode::Char('[') => {
|
||||||
|
let count = state.monitors.len();
|
||||||
|
if count > 0 {
|
||||||
|
let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
|
state.layout.selected = new_idx;
|
||||||
|
state.config.sync_from_monitor(new_idx, &state.monitors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(']') => {
|
||||||
|
let count = state.monitors.len();
|
||||||
|
if count > 0 {
|
||||||
|
let new_idx = (state.config.monitor_idx + 1) % count;
|
||||||
|
state.layout.selected = new_idx;
|
||||||
|
state.config.sync_from_monitor(new_idx, &state.monitors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => {
|
||||||
|
apply_current(state);
|
||||||
|
}
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
crate::ui::layout_view::trigger_save(state);
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
state.config.scale_editing = false;
|
||||||
|
// Re-sync from live monitor to discard pending edits
|
||||||
|
let idx = state.config.monitor_idx;
|
||||||
|
state.config.sync_from_monitor(idx, &state.monitors);
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if state.config.current_field() == ConfigField::Scale {
|
||||||
|
commit_scale(state);
|
||||||
|
}
|
||||||
|
apply_current(state);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.push_undo();
|
||||||
|
handle_field_key(event, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
|
if state.monitors.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let row = event.row;
|
||||||
|
// Content area starts at y=2 (after tab bar).
|
||||||
|
// Config view: header (Length(2)) at y=2-3, list block starts at y=4.
|
||||||
|
// List border top at y=4, items at y=5+.
|
||||||
|
let items_start_row = 5u16;
|
||||||
|
|
||||||
|
match event.kind {
|
||||||
|
MouseEventKind::Down(MouseButton::Left) => {
|
||||||
|
if row >= items_start_row {
|
||||||
|
let field_idx = (row - items_start_row) as usize;
|
||||||
|
if field_idx < ConfigField::ALL.len() {
|
||||||
|
state.config.focused = field_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollUp => {
|
||||||
|
state.push_undo();
|
||||||
|
let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
|
||||||
|
handle_field_key(fake_right, state);
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollDown => {
|
||||||
|
state.push_undo();
|
||||||
|
let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
|
||||||
|
handle_field_key(fake_left, state);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_field_key(event: KeyEvent, state: &mut AppState) {
|
||||||
|
let monitors_len = state.monitors.len();
|
||||||
|
if monitors_len == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let idx = state.config.monitor_idx.min(monitors_len - 1);
|
||||||
|
|
||||||
|
match state.config.current_field() {
|
||||||
|
ConfigField::Resolution => match event.code {
|
||||||
|
KeyCode::Char('h') | KeyCode::Left => {
|
||||||
|
if state.config.res_idx > 0 {
|
||||||
|
state.config.res_idx -= 1;
|
||||||
|
let m = &state.monitors[idx];
|
||||||
|
state.config.update_refreshes(m);
|
||||||
|
sync_mode_to_monitor(state, idx);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('l') | KeyCode::Right => {
|
||||||
|
if state.config.res_idx + 1 < state.config.resolutions.len() {
|
||||||
|
state.config.res_idx += 1;
|
||||||
|
let m = &state.monitors[idx];
|
||||||
|
state.config.update_refreshes(m);
|
||||||
|
sync_mode_to_monitor(state, idx);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
ConfigField::Refresh => match event.code {
|
||||||
|
KeyCode::Char('h') | KeyCode::Left => {
|
||||||
|
if state.config.refresh_idx > 0 {
|
||||||
|
state.config.refresh_idx -= 1;
|
||||||
|
sync_mode_to_monitor(state, idx);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('l') | KeyCode::Right => {
|
||||||
|
if state.config.refresh_idx + 1 < state.config.refreshes.len() {
|
||||||
|
state.config.refresh_idx += 1;
|
||||||
|
sync_mode_to_monitor(state, idx);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
ConfigField::Scale => match event.code {
|
||||||
|
KeyCode::Char(',') => {
|
||||||
|
let s = state.monitors[idx].scale - 0.1;
|
||||||
|
state.monitors[idx].scale = (s * 100.0).round() / 100.0;
|
||||||
|
state.monitors[idx].scale = state.monitors[idx].scale.max(0.1);
|
||||||
|
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('.') => {
|
||||||
|
let s = state.monitors[idx].scale + 0.1;
|
||||||
|
state.monitors[idx].scale = (s * 100.0).round() / 100.0;
|
||||||
|
state.monitors[idx].scale = state.monitors[idx].scale.min(10.0);
|
||||||
|
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => {
|
||||||
|
state.config.scale_editing = true;
|
||||||
|
state.config.scale_str.push(c);
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
state.config.scale_str.pop();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
ConfigField::Transform => match event.code {
|
||||||
|
KeyCode::Char('h') | KeyCode::Left => {
|
||||||
|
let all = Transform::all();
|
||||||
|
state.config.transform_idx = state
|
||||||
|
.config
|
||||||
|
.transform_idx
|
||||||
|
.checked_sub(1)
|
||||||
|
.unwrap_or(all.len() - 1);
|
||||||
|
state.monitors[idx].transform = all[state.config.transform_idx];
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('l') | KeyCode::Right => {
|
||||||
|
let all = Transform::all();
|
||||||
|
state.config.transform_idx = (state.config.transform_idx + 1) % all.len();
|
||||||
|
state.monitors[idx].transform = all[state.config.transform_idx];
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
ConfigField::Vrr => match event.code {
|
||||||
|
KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => {
|
||||||
|
state.monitors[idx].vrr = !state.monitors[idx].vrr;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
ConfigField::Dpms => match event.code {
|
||||||
|
KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => {
|
||||||
|
state.monitors[idx].dpms = !state.monitors[idx].dpms;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
ConfigField::MirrorOf => match event.code {
|
||||||
|
KeyCode::Char('h') | KeyCode::Left => {
|
||||||
|
if state.config.mirror_idx > 0 {
|
||||||
|
state.config.mirror_idx -= 1;
|
||||||
|
sync_mirror_to_monitor(state, idx);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('l') | KeyCode::Right => {
|
||||||
|
if state.config.mirror_idx + 1 < state.config.mirror_options.len() {
|
||||||
|
state.config.mirror_idx += 1;
|
||||||
|
sync_mirror_to_monitor(state, idx);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_mode_to_monitor(state: &mut AppState, idx: usize) {
|
||||||
|
if let Some(&(w, h)) = state.config.resolutions.get(state.config.res_idx) {
|
||||||
|
if let Some(&r) = state.config.refreshes.get(state.config.refresh_idx) {
|
||||||
|
state.monitors[idx].active_mode.width = w;
|
||||||
|
state.monitors[idx].active_mode.height = h;
|
||||||
|
state.monitors[idx].active_mode.refresh = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) {
|
||||||
|
let chosen = state.config.mirror_options.get(state.config.mirror_idx).cloned();
|
||||||
|
state.monitors[idx].mirror_of = match chosen.as_deref() {
|
||||||
|
Some("(none)") | None => None,
|
||||||
|
Some(s) => Some(s.to_owned()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_scale(state: &mut AppState) {
|
||||||
|
let idx = state.config.monitor_idx.min(state.monitors.len().saturating_sub(1));
|
||||||
|
if let Ok(v) = state.config.scale_str.parse::<f64>() {
|
||||||
|
state.monitors[idx].scale = v.clamp(0.1, 10.0);
|
||||||
|
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
state.config.scale_editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_current(state: &mut AppState) {
|
||||||
|
state.pending_apply = true;
|
||||||
|
state.set_status("Applying...", StatusLevel::Info);
|
||||||
|
state.dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
if state.monitors.is_empty() {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new("No monitors detected.").style(Style::default().fg(Color::DarkGray)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = state.config.monitor_idx.min(state.monitors.len() - 1);
|
||||||
|
let m = &state.monitors[idx];
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(2), Constraint::Min(0)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Monitor header with PPI hint
|
||||||
|
let ppi_hint = match (m.ppi(), m.suggested_scale()) {
|
||||||
|
(Some(ppi), Some(scale)) => format!(" │ {:.0} PPI (suggested scale: {:.2})", ppi, scale),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let header = format!(" {} — {}{}", m.name, m.description, ppi_hint);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(header).style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||||
|
chunks[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
let form_area = chunks[1];
|
||||||
|
let row_height = 1u16;
|
||||||
|
let fields = ConfigField::ALL;
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = fields
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &field)| {
|
||||||
|
let is_focused = i == state.config.focused;
|
||||||
|
let value = field_value(field, state, m);
|
||||||
|
let label = format!(" {:12} {}", field.label(), value);
|
||||||
|
let style = if is_focused {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
.bg(Color::DarkGray)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
ListItem::new(Line::from(Span::styled(label, style)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let _ = row_height; // used implicitly via ListItem heights
|
||||||
|
let list = List::new(items).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
|
.title(" Config "),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
list_state.select(Some(state.config.focused));
|
||||||
|
f.render_stateful_widget(list, form_area, &mut list_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_value(field: ConfigField, state: &AppState, m: &Monitor) -> String {
|
||||||
|
match field {
|
||||||
|
ConfigField::Resolution => {
|
||||||
|
if let Some(&(w, h)) = state.config.resolutions.get(state.config.res_idx) {
|
||||||
|
format!("{}x{} (h/l to change)", w, h)
|
||||||
|
} else {
|
||||||
|
format!("{}x{}", m.active_mode.width, m.active_mode.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConfigField::Refresh => {
|
||||||
|
if let Some(&r) = state.config.refreshes.get(state.config.refresh_idx) {
|
||||||
|
format!("{:.2} Hz (h/l to change)", r)
|
||||||
|
} else {
|
||||||
|
format!("{:.2} Hz", m.active_mode.refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConfigField::Scale => {
|
||||||
|
if state.config.scale_editing {
|
||||||
|
format!("{}| (Enter to commit, ,/. for ±0.1)", state.config.scale_str)
|
||||||
|
} else {
|
||||||
|
format!("{} (,/. for ±0.1)", state.config.scale_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConfigField::Transform => Transform::all()
|
||||||
|
[state.config.transform_idx.min(Transform::all().len() - 1)]
|
||||||
|
.label()
|
||||||
|
.to_owned(),
|
||||||
|
ConfigField::Vrr => {
|
||||||
|
if m.vrr { "ON".to_owned() } else { "OFF".to_owned() }
|
||||||
|
}
|
||||||
|
ConfigField::Dpms => {
|
||||||
|
if m.dpms { "ON".to_owned() } else { "OFF".to_owned() }
|
||||||
|
}
|
||||||
|
ConfigField::MirrorOf => state
|
||||||
|
.config
|
||||||
|
.mirror_options
|
||||||
|
.get(state.config.mirror_idx)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "(none)".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
332
src/ui/layout_view.rs
Normal file
332
src/ui/layout_view.rs
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::Span,
|
||||||
|
widgets::{Block, BorderType, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
layout::{auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position, world_to_canvas},
|
||||||
|
monitor::Monitor,
|
||||||
|
ui::{AppState, DragState, StatusLevel, Tab},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
|
let shift = event.modifiers.contains(KeyModifiers::SHIFT);
|
||||||
|
let step = if shift { 10 } else { 1 };
|
||||||
|
let count = state.monitors.len();
|
||||||
|
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Char('h') | KeyCode::Left => {
|
||||||
|
state.push_undo();
|
||||||
|
move_selected(&state.layout, &mut state.monitors, -step, 0);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('l') | KeyCode::Right => {
|
||||||
|
state.push_undo();
|
||||||
|
move_selected(&state.layout, &mut state.monitors, step, 0);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('k') | KeyCode::Up => {
|
||||||
|
state.push_undo();
|
||||||
|
move_selected(&state.layout, &mut state.monitors, 0, -step);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
|
state.push_undo();
|
||||||
|
move_selected(&state.layout, &mut state.monitors, 0, step);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count),
|
||||||
|
KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(count),
|
||||||
|
KeyCode::Char('[') => state.layout.zoom = (state.layout.zoom - 0.1).max(0.1),
|
||||||
|
KeyCode::Char(']') => state.layout.zoom = (state.layout.zoom + 0.1).min(5.0),
|
||||||
|
KeyCode::Char('0') => {
|
||||||
|
state.push_undo();
|
||||||
|
auto_arrange(&mut state.monitors);
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
state.config.sync_from_monitor(state.layout.selected, &state.monitors);
|
||||||
|
state.tab = Tab::Config;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
|
let col = event.column;
|
||||||
|
let row = event.row;
|
||||||
|
|
||||||
|
match event.kind {
|
||||||
|
MouseEventKind::Down(MouseButton::Left) => {
|
||||||
|
let canvas = canvas_area(state.terminal_size);
|
||||||
|
if let Some(idx) = monitor_at(col, row, canvas, state) {
|
||||||
|
let (min_x, min_y, _, _) = bounding_box(&state.monitors);
|
||||||
|
let scale = canvas_scale_for(canvas, state);
|
||||||
|
let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1);
|
||||||
|
|
||||||
|
// Push undo at drag start, not on every move
|
||||||
|
state.push_undo();
|
||||||
|
state.layout.selected = idx;
|
||||||
|
state.drag_state = Some(DragState {
|
||||||
|
monitor_idx: idx,
|
||||||
|
origin_x: state.monitors[idx].x,
|
||||||
|
origin_y: state.monitors[idx].y,
|
||||||
|
click_world_x: wx,
|
||||||
|
click_world_y: wy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseEventKind::Drag(MouseButton::Left) => {
|
||||||
|
if let Some(ref drag) = state.drag_state {
|
||||||
|
let canvas = canvas_area(state.terminal_size);
|
||||||
|
let (min_x, min_y, _, _) = bounding_box(&state.monitors);
|
||||||
|
let scale = canvas_scale_for(canvas, state);
|
||||||
|
let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1);
|
||||||
|
|
||||||
|
let idx = drag.monitor_idx;
|
||||||
|
let new_x = drag.origin_x + (wx - drag.click_world_x);
|
||||||
|
let new_y = drag.origin_y + (wy - drag.click_world_y);
|
||||||
|
let (sx, sy) = snap_position(idx, new_x, new_y, &state.monitors, state.layout.snap_threshold);
|
||||||
|
state.monitors[idx].x = sx;
|
||||||
|
state.monitors[idx].y = sy;
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseEventKind::Up(MouseButton::Left) => {
|
||||||
|
state.drag_state = None;
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollUp => {
|
||||||
|
let canvas = canvas_area(state.terminal_size);
|
||||||
|
if in_canvas(col, row, canvas) {
|
||||||
|
state.layout.zoom = (state.layout.zoom + 0.1).min(5.0);
|
||||||
|
} else {
|
||||||
|
state.layout.prev(state.monitors.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollDown => {
|
||||||
|
let canvas = canvas_area(state.terminal_size);
|
||||||
|
if in_canvas(col, row, canvas) {
|
||||||
|
state.layout.zoom = (state.layout.zoom - 0.1).max(0.1);
|
||||||
|
} else {
|
||||||
|
state.layout.next(state.monitors.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
if state.monitors.is_empty() {
|
||||||
|
let msg = Paragraph::new("No monitors detected. Is Hyprland running?")
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
f.render_widget(msg, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(0), Constraint::Length(1)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_canvas(f, chunks[0], state);
|
||||||
|
render_readout(f, chunks[1], state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let monitors = &state.monitors;
|
||||||
|
let (min_x, min_y, _, _) = bounding_box(monitors);
|
||||||
|
let scale = canvas_scale_for(area, state);
|
||||||
|
|
||||||
|
// Pre-compute which monitors are overlapping (shown with red borders)
|
||||||
|
let overlapping = overlapping_monitors(monitors);
|
||||||
|
|
||||||
|
let selected = state.layout.selected;
|
||||||
|
let draw_order: Vec<usize> = (0..monitors.len())
|
||||||
|
.filter(|&i| i != selected)
|
||||||
|
.chain(std::iter::once(selected))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for i in draw_order {
|
||||||
|
let m = &monitors[i];
|
||||||
|
let (cx, cy) = world_to_canvas(m.x, m.y, scale, min_x, min_y, area.x + 1, area.y + 1);
|
||||||
|
let cw = ((m.world_width() as f32 * scale) as u16).max(4);
|
||||||
|
let ch = ((m.world_height() as f32 * scale * 0.5) as u16).max(2);
|
||||||
|
|
||||||
|
let cw = cw.min(area.width.saturating_sub(cx.saturating_sub(area.x)));
|
||||||
|
let ch = ch.min(area.height.saturating_sub(cy.saturating_sub(area.y)));
|
||||||
|
if cw == 0 || ch == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rect = Rect { x: cx, y: cy, width: cw, height: ch };
|
||||||
|
|
||||||
|
let is_selected = i == selected;
|
||||||
|
let is_dragging = state.drag_state.as_ref().map(|d| d.monitor_idx == i).unwrap_or(false);
|
||||||
|
let is_overlapping = overlapping[i];
|
||||||
|
|
||||||
|
let border_style = if is_dragging {
|
||||||
|
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_overlapping {
|
||||||
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_selected {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Blue)
|
||||||
|
};
|
||||||
|
|
||||||
|
let label = format!(" {} ", m.name);
|
||||||
|
let mode_str = format!(" {}@{:.0}Hz ", m.active_mode.width, m.active_mode.refresh);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(if is_selected || is_dragging || is_overlapping {
|
||||||
|
BorderType::Thick
|
||||||
|
} else {
|
||||||
|
BorderType::Rounded
|
||||||
|
})
|
||||||
|
.border_style(border_style)
|
||||||
|
.title(Span::styled(&label, border_style))
|
||||||
|
.title_bottom(Span::styled(&mode_str, Style::default().fg(Color::DarkGray)));
|
||||||
|
|
||||||
|
f.render_widget(block, rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_readout(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
if state.monitors.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let idx = state.layout.selected.min(state.monitors.len() - 1);
|
||||||
|
let m = &state.monitors[idx];
|
||||||
|
|
||||||
|
let mirror_info = m.mirror_of.as_ref()
|
||||||
|
.map(|src| format!(" mirror:{}", src))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let overlap_warn = if overlapping_monitors(&state.monitors)[idx] {
|
||||||
|
" ⚠ OVERLAP"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let drag_hint = if state.drag_state.is_some() { " [dragging]" } else { "" };
|
||||||
|
|
||||||
|
let text = format!(
|
||||||
|
" {} x:{} y:{} {}x{}@{:.0}Hz scale:{:.2}{}{}{}",
|
||||||
|
m.name, m.x, m.y,
|
||||||
|
m.active_mode.width, m.active_mode.height, m.active_mode.refresh,
|
||||||
|
m.scale,
|
||||||
|
mirror_info, overlap_warn, drag_hint,
|
||||||
|
);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(text).style(Style::default().fg(Color::Cyan)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a bool per monitor: true if it overlaps any other monitor.
|
||||||
|
fn overlapping_monitors(monitors: &[Monitor]) -> Vec<bool> {
|
||||||
|
let mut flags = vec![false; monitors.len()];
|
||||||
|
for i in 0..monitors.len() {
|
||||||
|
for j in (i + 1)..monitors.len() {
|
||||||
|
let a = &monitors[i];
|
||||||
|
let b = &monitors[j];
|
||||||
|
if a.x < b.right_edge() && a.right_edge() > b.x
|
||||||
|
&& a.y < b.bottom_edge() && a.bottom_edge() > b.y
|
||||||
|
{
|
||||||
|
flags[i] = true;
|
||||||
|
flags[j] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flags
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canvas rect from terminal size.
|
||||||
|
/// Tab bar = y 0-1 (height 2), bottom bar = last row (height 1), readout = 1 row inside content.
|
||||||
|
pub fn canvas_area(terminal_size: (u16, u16)) -> Rect {
|
||||||
|
let (tw, th) = terminal_size;
|
||||||
|
Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 2,
|
||||||
|
width: tw,
|
||||||
|
height: th.saturating_sub(4),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_canvas(col: u16, row: u16, canvas: Rect) -> bool {
|
||||||
|
col >= canvas.x + 1
|
||||||
|
&& col < canvas.x + canvas.width.saturating_sub(1)
|
||||||
|
&& row >= canvas.y + 1
|
||||||
|
&& row < canvas.y + canvas.height.saturating_sub(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canvas_scale_for(area: Rect, state: &AppState) -> f32 {
|
||||||
|
let w = area.width.saturating_sub(2);
|
||||||
|
let h = area.height.saturating_sub(2);
|
||||||
|
canvas_scale(&state.monitors, w, h, state.layout.zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hit-test: which monitor (if any) is at canvas position (col, row)?
|
||||||
|
fn monitor_at(col: u16, row: u16, canvas: Rect, state: &AppState) -> Option<usize> {
|
||||||
|
if state.monitors.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let monitors = &state.monitors;
|
||||||
|
let (min_x, min_y, _, _) = bounding_box(monitors);
|
||||||
|
let scale = canvas_scale_for(canvas, state);
|
||||||
|
let pad_x = canvas.x + 1;
|
||||||
|
let pad_y = canvas.y + 1;
|
||||||
|
|
||||||
|
// Check selected first (renders on top)
|
||||||
|
let selected = state.layout.selected;
|
||||||
|
let order: Vec<usize> = std::iter::once(selected)
|
||||||
|
.chain((0..monitors.len()).filter(|&i| i != selected))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for i in order {
|
||||||
|
let m = &monitors[i];
|
||||||
|
let (cx, cy) = world_to_canvas(m.x, m.y, scale, min_x, min_y, pad_x, pad_y);
|
||||||
|
let cw = ((m.world_width() as f32 * scale) as u16).max(4);
|
||||||
|
let ch = ((m.world_height() as f32 * scale * 0.5) as u16).max(2);
|
||||||
|
if col >= cx && col < cx + cw && row >= cy && row < cy + ch {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trigger_save(state: &mut AppState) {
|
||||||
|
use crate::monitor::format_hypr_line;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
let path: PathBuf = dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()))
|
||||||
|
.join("hypr/monitors.conf");
|
||||||
|
|
||||||
|
let is_new = !path.exists();
|
||||||
|
let mut lines = vec!["# Generated by breadmon — do not edit by hand".to_owned()];
|
||||||
|
for m in &state.monitors {
|
||||||
|
lines.push(format_hypr_line(m));
|
||||||
|
}
|
||||||
|
let content = lines.join("\n") + "\n";
|
||||||
|
|
||||||
|
match std::fs::write(&path, &content) {
|
||||||
|
Ok(()) => {
|
||||||
|
if is_new {
|
||||||
|
state.set_status(
|
||||||
|
format!("Saved. Add: source = {} to hyprland.conf", path.display()),
|
||||||
|
StatusLevel::Success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state.set_status(format!("Saved to {}", path.display()), StatusLevel::Success);
|
||||||
|
}
|
||||||
|
state.dirty = false;
|
||||||
|
}
|
||||||
|
Err(e) => state.set_status(format!("Save failed: {}", e), StatusLevel::Error),
|
||||||
|
}
|
||||||
|
}
|
||||||
420
src/ui/mirror_view.rs
Normal file
420
src/ui/mirror_view.rs
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
mirror::{find_mirror_modes, refresh_match_label, MirrorResult},
|
||||||
|
ui::{AppState, StatusLevel},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MirrorField {
|
||||||
|
Source,
|
||||||
|
Target,
|
||||||
|
Compute,
|
||||||
|
Apply,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct MirrorState {
|
||||||
|
pub source_idx: usize,
|
||||||
|
pub target_idx: usize,
|
||||||
|
pub result: Option<MirrorResult>,
|
||||||
|
pub focused: usize, // index into FIELDS
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS: &[MirrorField] = &[
|
||||||
|
MirrorField::Source,
|
||||||
|
MirrorField::Target,
|
||||||
|
MirrorField::Compute,
|
||||||
|
MirrorField::Apply,
|
||||||
|
MirrorField::Cancel,
|
||||||
|
];
|
||||||
|
|
||||||
|
impl MirrorState {
|
||||||
|
fn current_field(&self) -> MirrorField {
|
||||||
|
FIELDS[self.focused.min(FIELDS.len() - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_field(&mut self) {
|
||||||
|
// Skip Apply/Cancel if no result yet
|
||||||
|
let mut next = (self.focused + 1) % FIELDS.len();
|
||||||
|
if self.result.is_none() && (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel) {
|
||||||
|
next = 0;
|
||||||
|
}
|
||||||
|
self.focused = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev_field(&mut self) {
|
||||||
|
let len = FIELDS.len();
|
||||||
|
let mut prev = self.focused.checked_sub(1).unwrap_or(len - 1);
|
||||||
|
if self.result.is_none() && (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel) {
|
||||||
|
prev = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2);
|
||||||
|
}
|
||||||
|
self.focused = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure source and target are different
|
||||||
|
pub fn fix_indices(&mut self, count: usize) {
|
||||||
|
if count < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.source_idx == self.target_idx {
|
||||||
|
self.target_idx = (self.source_idx + 1) % count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
|
let count = state.monitors.len();
|
||||||
|
if count < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Tab | KeyCode::Char('j') | KeyCode::Down => {
|
||||||
|
state.mirror.next_field();
|
||||||
|
}
|
||||||
|
KeyCode::BackTab | KeyCode::Char('k') | KeyCode::Up => {
|
||||||
|
state.mirror.prev_field();
|
||||||
|
}
|
||||||
|
KeyCode::Char('h') | KeyCode::Left => match state.mirror.current_field() {
|
||||||
|
MirrorField::Source => {
|
||||||
|
state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
|
state.mirror.fix_indices(count);
|
||||||
|
state.mirror.result = None;
|
||||||
|
}
|
||||||
|
MirrorField::Target => {
|
||||||
|
state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
|
state.mirror.fix_indices(count);
|
||||||
|
state.mirror.result = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
KeyCode::Char('l') | KeyCode::Right => match state.mirror.current_field() {
|
||||||
|
MirrorField::Source => {
|
||||||
|
state.mirror.source_idx = (state.mirror.source_idx + 1) % count;
|
||||||
|
state.mirror.fix_indices(count);
|
||||||
|
state.mirror.result = None;
|
||||||
|
}
|
||||||
|
MirrorField::Target => {
|
||||||
|
state.mirror.target_idx = (state.mirror.target_idx + 1) % count;
|
||||||
|
state.mirror.fix_indices(count);
|
||||||
|
state.mirror.result = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
KeyCode::Enter => match state.mirror.current_field() {
|
||||||
|
MirrorField::Compute => {
|
||||||
|
let src = &state.monitors[state.mirror.source_idx];
|
||||||
|
let tgt = &state.monitors[state.mirror.target_idx];
|
||||||
|
match find_mirror_modes(src, tgt) {
|
||||||
|
Some(result) => {
|
||||||
|
state.mirror.result = Some(result);
|
||||||
|
// Move focus to Apply
|
||||||
|
state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
state.set_status(
|
||||||
|
"No compatible modes found between these monitors.",
|
||||||
|
StatusLevel::Error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MirrorField::Apply => {
|
||||||
|
if let Some(result) = state.mirror.result.clone() {
|
||||||
|
state.push_undo();
|
||||||
|
let src_name = state.monitors[state.mirror.source_idx].name.clone();
|
||||||
|
let tgt_idx = state.mirror.target_idx;
|
||||||
|
|
||||||
|
state.monitors[tgt_idx].active_mode = result.mirror_mode.clone();
|
||||||
|
state.monitors[tgt_idx].mirror_of = Some(src_name.clone());
|
||||||
|
|
||||||
|
state.dirty = true;
|
||||||
|
state.mirror.result = None;
|
||||||
|
state.mirror.focused = 0;
|
||||||
|
state.set_status(
|
||||||
|
format!(
|
||||||
|
"Mirror set: {} → {} at {}",
|
||||||
|
src_name,
|
||||||
|
state.monitors[tgt_idx].name,
|
||||||
|
result.mirror_mode
|
||||||
|
),
|
||||||
|
StatusLevel::Success,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MirrorField::Cancel => {
|
||||||
|
state.mirror.result = None;
|
||||||
|
state.mirror.focused = 0;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
KeyCode::Esc => {
|
||||||
|
state.mirror.result = None;
|
||||||
|
state.mirror.focused = 0;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
|
let count = state.monitors.len();
|
||||||
|
if count < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = event.row;
|
||||||
|
// Content area starts at y=2.
|
||||||
|
// Pickers chunk: y=2..5 (height 4). Source line at y=2, target at y=4.
|
||||||
|
// Compute button: y=6.
|
||||||
|
// Result panel: y=7+ (border at y=7, buttons at ~y=13 for a typical terminal).
|
||||||
|
|
||||||
|
match event.kind {
|
||||||
|
MouseEventKind::Down(MouseButton::Left) => {
|
||||||
|
match row {
|
||||||
|
2 | 3 => {
|
||||||
|
// Source picker area
|
||||||
|
let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Source).unwrap_or(0);
|
||||||
|
state.mirror.focused = f_idx;
|
||||||
|
}
|
||||||
|
4 | 5 => {
|
||||||
|
// Target picker area
|
||||||
|
let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Target).unwrap_or(1);
|
||||||
|
state.mirror.focused = f_idx;
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
|
// Compute button
|
||||||
|
let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2);
|
||||||
|
state.mirror.focused = f_idx;
|
||||||
|
// Also activate it
|
||||||
|
let src = &state.monitors[state.mirror.source_idx];
|
||||||
|
let tgt = &state.monitors[state.mirror.target_idx];
|
||||||
|
match crate::mirror::find_mirror_modes(src, tgt) {
|
||||||
|
Some(result) => {
|
||||||
|
state.mirror.result = Some(result);
|
||||||
|
state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
state.set_status(
|
||||||
|
"No compatible modes found between these monitors.",
|
||||||
|
crate::ui::StatusLevel::Error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r if r >= 7 => {
|
||||||
|
// Result panel: Apply is on the line with buttons.
|
||||||
|
// Rough column check: col < 20 = Apply, col >= 20 = Cancel
|
||||||
|
if state.mirror.result.is_some() {
|
||||||
|
let col = event.column;
|
||||||
|
if col < 20 {
|
||||||
|
// Activate Apply
|
||||||
|
state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3);
|
||||||
|
if let Some(result) = state.mirror.result.clone() {
|
||||||
|
state.push_undo();
|
||||||
|
let src_name = state.monitors[state.mirror.source_idx].name.clone();
|
||||||
|
let tgt_idx = state.mirror.target_idx;
|
||||||
|
state.monitors[tgt_idx].active_mode = result.mirror_mode.clone();
|
||||||
|
state.monitors[tgt_idx].mirror_of = Some(src_name.clone());
|
||||||
|
state.dirty = true;
|
||||||
|
state.mirror.result = None;
|
||||||
|
state.mirror.focused = 0;
|
||||||
|
state.set_status(
|
||||||
|
format!("Mirror set: {} → {} at {}", src_name, state.monitors[tgt_idx].name, result.mirror_mode),
|
||||||
|
crate::ui::StatusLevel::Success,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cancel
|
||||||
|
state.mirror.result = None;
|
||||||
|
state.mirror.focused = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollUp => {
|
||||||
|
// Scroll in source/target pickers to cycle monitors
|
||||||
|
match state.mirror.current_field() {
|
||||||
|
MirrorField::Source => {
|
||||||
|
state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
|
state.mirror.fix_indices(count);
|
||||||
|
state.mirror.result = None;
|
||||||
|
}
|
||||||
|
MirrorField::Target => {
|
||||||
|
state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
|
state.mirror.fix_indices(count);
|
||||||
|
state.mirror.result = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollDown => {
|
||||||
|
match state.mirror.current_field() {
|
||||||
|
MirrorField::Source => {
|
||||||
|
state.mirror.source_idx = (state.mirror.source_idx + 1) % count;
|
||||||
|
state.mirror.fix_indices(count);
|
||||||
|
state.mirror.result = None;
|
||||||
|
}
|
||||||
|
MirrorField::Target => {
|
||||||
|
state.mirror.target_idx = (state.mirror.target_idx + 1) % count;
|
||||||
|
state.mirror.fix_indices(count);
|
||||||
|
state.mirror.result = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let count = state.monitors.len();
|
||||||
|
|
||||||
|
if count < 2 {
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new("Need at least 2 monitors for mirror setup.")
|
||||||
|
.style(Style::default().fg(Color::DarkGray)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(4), // pickers
|
||||||
|
Constraint::Length(1), // compute button
|
||||||
|
Constraint::Min(0), // result / hint
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_pickers(f, chunks[0], state, count);
|
||||||
|
render_compute_btn(f, chunks[1], state);
|
||||||
|
if let Some(ref result) = state.mirror.result {
|
||||||
|
render_result(f, chunks[2], state, result);
|
||||||
|
} else {
|
||||||
|
let hint = Paragraph::new(" Press Enter on [Compute] to find the best matching modes.")
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
f.render_widget(hint, chunks[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) {
|
||||||
|
let src_name = state
|
||||||
|
.monitors
|
||||||
|
.get(state.mirror.source_idx.min(count - 1))
|
||||||
|
.map(|m| m.name.as_str())
|
||||||
|
.unwrap_or("?");
|
||||||
|
let tgt_name = state
|
||||||
|
.monitors
|
||||||
|
.get(state.mirror.target_idx.min(count - 1))
|
||||||
|
.map(|m| m.name.as_str())
|
||||||
|
.unwrap_or("?");
|
||||||
|
|
||||||
|
let focused = state.mirror.current_field();
|
||||||
|
|
||||||
|
let src_style = if focused == MirrorField::Source {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
let tgt_style = if focused == MirrorField::Target {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(" Source "),
|
||||||
|
Span::styled(format!("[ {} ]", src_name), src_style),
|
||||||
|
Span::raw(" (mirror this output)"),
|
||||||
|
]),
|
||||||
|
Line::raw(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(" Target "),
|
||||||
|
Span::styled(format!("[ {} ]", tgt_name), tgt_style),
|
||||||
|
Span::raw(" (the mirroring output)"),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
f.render_widget(Paragraph::new(lines), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_compute_btn(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let focused = state.mirror.current_field() == MirrorField::Compute;
|
||||||
|
let style = if focused {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(" [ Compute Best Match ]").style(style),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorResult) {
|
||||||
|
let ar = result.ar_ratio;
|
||||||
|
let ar_label = if result.ar_exact {
|
||||||
|
format!("{}:{} (exact)", ar.0, ar.1)
|
||||||
|
} else {
|
||||||
|
format!("{}:{} (approx)", ar.0, ar.1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let refresh_label = refresh_match_label(result);
|
||||||
|
|
||||||
|
let focused = state.mirror.current_field();
|
||||||
|
let apply_style = if focused == MirrorField::Apply {
|
||||||
|
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
let cancel_style = if focused == MirrorField::Cancel {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::raw(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!(" Source mode: {} (AR {})", result.source_mode, ar_label),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!(" Mirror mode: {}", result.mirror_mode),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!(" Refresh: {:.2} Hz ({})", result.refresh, refresh_label),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
)),
|
||||||
|
Line::raw(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("[ Apply Mirror ]", apply_style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("[ Cancel ]", cancel_style),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
|
.title(" Result ");
|
||||||
|
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
f.render_widget(Paragraph::new(lines), inner);
|
||||||
|
}
|
||||||
322
src/ui/mod.rs
Normal file
322
src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
pub mod config_view;
|
||||||
|
pub mod layout_view;
|
||||||
|
pub mod mirror_view;
|
||||||
|
pub mod profiles_view;
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Paragraph, Tabs},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
layout::LayoutState,
|
||||||
|
monitor::Monitor,
|
||||||
|
};
|
||||||
|
|
||||||
|
use config_view::ConfigState;
|
||||||
|
use mirror_view::MirrorState;
|
||||||
|
use profiles_view::ProfilesState;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Tab {
|
||||||
|
Layout,
|
||||||
|
Config,
|
||||||
|
Mirror,
|
||||||
|
Profiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tab {
|
||||||
|
pub fn index(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Layout => 0,
|
||||||
|
Self::Config => 1,
|
||||||
|
Self::Mirror => 2,
|
||||||
|
Self::Profiles => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_col(col: u16) -> Option<Self> {
|
||||||
|
// Tab titles: " 1 Layout "(10), " 2 Config "(10), " 3 Mirror "(10), " 4 Profiles "(12)
|
||||||
|
// Each separated by a 1-char divider │
|
||||||
|
const WIDTHS: [u16; 4] = [10, 10, 10, 12];
|
||||||
|
let mut x = 0u16;
|
||||||
|
for (i, &w) in WIDTHS.iter().enumerate() {
|
||||||
|
if col >= x && col < x + w {
|
||||||
|
return Some(match i {
|
||||||
|
0 => Self::Layout,
|
||||||
|
1 => Self::Config,
|
||||||
|
2 => Self::Mirror,
|
||||||
|
3 => Self::Profiles,
|
||||||
|
_ => unreachable!(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
x += w + 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hints(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Layout =>
|
||||||
|
" [hjkl/drag]move [Tab]next [Shift+hjkl]×10 [0]arrange [Ctrl+Z]undo [a]apply [s]save",
|
||||||
|
Self::Config =>
|
||||||
|
" [jk]field [hl]value [,.]scale [Enter]apply [Esc]cancel [\\[\\]]monitor [Ctrl+Z]undo",
|
||||||
|
Self::Mirror =>
|
||||||
|
" [Tab]focus [hl]cycle [Enter]confirm [Ctrl+Z]undo",
|
||||||
|
Self::Profiles =>
|
||||||
|
" [jk]nav [Enter]load [d×2]delete [Tab]name [Ctrl+Z]undo",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum StatusLevel {
|
||||||
|
Info,
|
||||||
|
Success,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StatusMsg {
|
||||||
|
pub text: String,
|
||||||
|
pub level: StatusLevel,
|
||||||
|
pub born: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drag state for moving monitors in the layout canvas.
|
||||||
|
pub struct DragState {
|
||||||
|
pub monitor_idx: usize,
|
||||||
|
pub origin_x: i32,
|
||||||
|
pub origin_y: i32,
|
||||||
|
pub click_world_x: i32,
|
||||||
|
pub click_world_y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub monitors: Vec<Monitor>,
|
||||||
|
pub tab: Tab,
|
||||||
|
pub layout: LayoutState,
|
||||||
|
pub config: ConfigState,
|
||||||
|
pub mirror: MirrorState,
|
||||||
|
pub profiles: ProfilesState,
|
||||||
|
pub status: Option<StatusMsg>,
|
||||||
|
pub dirty: bool,
|
||||||
|
pub quit_confirm: bool,
|
||||||
|
pub drag_state: Option<DragState>,
|
||||||
|
pub terminal_size: (u16, u16),
|
||||||
|
/// Set to true by any handler that wants `main.rs` to run `apply_monitors`.
|
||||||
|
pub pending_apply: bool,
|
||||||
|
/// Snapshots for Ctrl+Z undo (up to 20 deep).
|
||||||
|
pub undo_stack: Vec<Vec<Monitor>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(monitors: Vec<Monitor>, terminal_size: (u16, u16)) -> Self {
|
||||||
|
let profiles_list = crate::profile::list().unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
monitors,
|
||||||
|
tab: Tab::Layout,
|
||||||
|
layout: LayoutState::default(),
|
||||||
|
config: ConfigState::default(),
|
||||||
|
mirror: MirrorState::default(),
|
||||||
|
profiles: ProfilesState::new(profiles_list),
|
||||||
|
status: None,
|
||||||
|
dirty: false,
|
||||||
|
quit_confirm: false,
|
||||||
|
drag_state: None,
|
||||||
|
terminal_size,
|
||||||
|
pending_apply: false,
|
||||||
|
undo_stack: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_status(&mut self, text: impl Into<String>, level: StatusLevel) {
|
||||||
|
self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick_status(&mut self) {
|
||||||
|
if let Some(s) = &self.status {
|
||||||
|
if s.born.elapsed().as_secs() >= 3 {
|
||||||
|
self.status = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn switch_tab(&mut self, tab: Tab) {
|
||||||
|
self.tab = tab;
|
||||||
|
if tab == Tab::Config {
|
||||||
|
self.config.sync_from_monitor(self.layout.selected, &self.monitors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a monitor snapshot for undo (max 20 entries).
|
||||||
|
pub fn push_undo(&mut self) {
|
||||||
|
self.undo_stack.push(self.monitors.clone());
|
||||||
|
if self.undo_stack.len() > 20 {
|
||||||
|
self.undo_stack.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the most recent undo snapshot.
|
||||||
|
pub fn undo(&mut self) {
|
||||||
|
if let Some(snapshot) = self.undo_stack.pop() {
|
||||||
|
self.monitors = snapshot;
|
||||||
|
self.dirty = true;
|
||||||
|
self.layout.clamp_selected(self.monitors.len());
|
||||||
|
// Re-sync config view to the restored state
|
||||||
|
let idx = self.layout.selected;
|
||||||
|
self.config.sync_from_monitor(idx, &self.monitors);
|
||||||
|
self.set_status("Undone.", StatusLevel::Info);
|
||||||
|
} else {
|
||||||
|
self.set_status("Nothing to undo.", StatusLevel::Info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the app should continue running, false to quit.
|
||||||
|
pub fn handle_key(event: KeyEvent, state: &mut AppState) -> bool {
|
||||||
|
// Quit
|
||||||
|
if event.code == KeyCode::Char('q')
|
||||||
|
|| (event.code == KeyCode::Char('c') && event.modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
{
|
||||||
|
if state.dirty && !state.quit_confirm {
|
||||||
|
state.set_status("Unsaved changes. Press q again to quit.", StatusLevel::Info);
|
||||||
|
state.quit_confirm = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.quit_confirm = false;
|
||||||
|
|
||||||
|
// Global: Ctrl+Z undo
|
||||||
|
if event.code == KeyCode::Char('z') && event.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
state.undo();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global tab switching
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Char('1') | KeyCode::F(1) => { state.switch_tab(Tab::Layout); return true; }
|
||||||
|
KeyCode::Char('2') | KeyCode::F(2) => { state.switch_tab(Tab::Config); return true; }
|
||||||
|
KeyCode::Char('3') | KeyCode::F(3) => { state.switch_tab(Tab::Mirror); return true; }
|
||||||
|
KeyCode::Char('4') | KeyCode::F(4) => { state.switch_tab(Tab::Profiles); return true; }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.tab {
|
||||||
|
Tab::Layout => layout_view::handle_key(event, state),
|
||||||
|
Tab::Config => config_view::handle_key(event, state),
|
||||||
|
Tab::Mirror => mirror_view::handle_key(event, state),
|
||||||
|
Tab::Profiles => profiles_view::handle_key(event, state),
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
|
let col = event.column;
|
||||||
|
let row = event.row;
|
||||||
|
let (_, th) = state.terminal_size;
|
||||||
|
|
||||||
|
// Tab bar: rows 0-1
|
||||||
|
if row < 2 {
|
||||||
|
if event.kind == MouseEventKind::Down(MouseButton::Left) {
|
||||||
|
if let Some(tab) = Tab::from_col(col) {
|
||||||
|
state.switch_tab(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom bar: last row
|
||||||
|
if row >= th.saturating_sub(1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.tab {
|
||||||
|
Tab::Layout => layout_view::handle_mouse(event, state),
|
||||||
|
Tab::Config => config_view::handle_mouse(event, state),
|
||||||
|
Tab::Mirror => mirror_view::handle_mouse(event, state),
|
||||||
|
Tab::Profiles => profiles_view::handle_mouse(event, state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, state: &AppState) {
|
||||||
|
let area = f.area();
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_tabs(f, chunks[0], state);
|
||||||
|
|
||||||
|
match state.tab {
|
||||||
|
Tab::Layout => layout_view::render(f, chunks[1], state),
|
||||||
|
Tab::Config => config_view::render(f, chunks[1], state),
|
||||||
|
Tab::Mirror => mirror_view::render(f, chunks[1], state),
|
||||||
|
Tab::Profiles => profiles_view::render(f, chunks[1], state),
|
||||||
|
}
|
||||||
|
|
||||||
|
render_bottom_bar(f, chunks[2], state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tabs(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let titles: Vec<Line> = vec![
|
||||||
|
Line::from(Span::raw(" 1 Layout ")),
|
||||||
|
Line::from(Span::raw(" 2 Config ")),
|
||||||
|
Line::from(Span::raw(" 3 Mirror ")),
|
||||||
|
Line::from(Span::raw(" 4 Profiles ")),
|
||||||
|
];
|
||||||
|
let tabs = Tabs::new(titles)
|
||||||
|
.block(Block::default())
|
||||||
|
.select(state.tab.index())
|
||||||
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.divider(Span::raw("│"));
|
||||||
|
|
||||||
|
f.render_widget(tabs, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_bottom_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let dirty_marker = if state.dirty { " [modified]" } else { "" };
|
||||||
|
let hints = format!("{}{} [q]uit", state.tab.hints(), dirty_marker);
|
||||||
|
|
||||||
|
let (status_text, status_style) = if let Some(msg) = &state.status {
|
||||||
|
let color = match msg.level {
|
||||||
|
StatusLevel::Info => Color::Cyan,
|
||||||
|
StatusLevel::Success => Color::Green,
|
||||||
|
StatusLevel::Error => Color::Red,
|
||||||
|
};
|
||||||
|
(format!(" {} ", msg.text), Style::default().fg(color))
|
||||||
|
} else {
|
||||||
|
(String::new(), Style::default())
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_width = status_text.len() as u16;
|
||||||
|
let bar_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Min(0), Constraint::Length(status_width)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(hints).style(Style::default().fg(Color::DarkGray)),
|
||||||
|
bar_chunks[0],
|
||||||
|
);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(status_text).style(status_style),
|
||||||
|
bar_chunks[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
381
src/ui/profiles_view.rs
Normal file
381
src/ui/profiles_view.rs
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
profile,
|
||||||
|
ui::{AppState, StatusLevel},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ProfileField {
|
||||||
|
List,
|
||||||
|
NameInput,
|
||||||
|
SaveBtn,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProfilesState {
|
||||||
|
pub profiles: Vec<String>,
|
||||||
|
pub selected_idx: usize,
|
||||||
|
pub new_name: String,
|
||||||
|
pub focused: ProfileField,
|
||||||
|
pub delete_confirm: Option<(String, Instant)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfilesState {
|
||||||
|
pub fn new(profiles: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
profiles,
|
||||||
|
selected_idx: 0,
|
||||||
|
new_name: String::new(),
|
||||||
|
focused: ProfileField::List,
|
||||||
|
delete_confirm: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(&mut self) {
|
||||||
|
self.profiles = profile::list().unwrap_or_default();
|
||||||
|
if self.selected_idx >= self.profiles.len() && !self.profiles.is_empty() {
|
||||||
|
self.selected_idx = self.profiles.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tick_delete_confirm(&mut self) {
|
||||||
|
if let Some((_, born)) = &self.delete_confirm {
|
||||||
|
if born.elapsed().as_secs() >= 3 {
|
||||||
|
self.delete_confirm = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
|
state.profiles.tick_delete_confirm();
|
||||||
|
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
state.profiles.focused = match state.profiles.focused {
|
||||||
|
ProfileField::List => ProfileField::NameInput,
|
||||||
|
ProfileField::NameInput => ProfileField::SaveBtn,
|
||||||
|
ProfileField::SaveBtn => ProfileField::List,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
state.profiles.focused = match state.profiles.focused {
|
||||||
|
ProfileField::List => ProfileField::SaveBtn,
|
||||||
|
ProfileField::NameInput => ProfileField::List,
|
||||||
|
ProfileField::SaveBtn => ProfileField::NameInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
state.profiles.new_name.clear();
|
||||||
|
state.profiles.delete_confirm = None;
|
||||||
|
state.profiles.focused = ProfileField::List;
|
||||||
|
}
|
||||||
|
_ => match state.profiles.focused {
|
||||||
|
ProfileField::List => handle_list_key(event, state),
|
||||||
|
ProfileField::NameInput => handle_name_key(event, state),
|
||||||
|
ProfileField::SaveBtn => {
|
||||||
|
if event.code == KeyCode::Enter {
|
||||||
|
do_save(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_list_key(event: KeyEvent, state: &mut AppState) {
|
||||||
|
let count = state.profiles.profiles.len();
|
||||||
|
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
|
if count > 0 {
|
||||||
|
state.profiles.selected_idx = (state.profiles.selected_idx + 1) % count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('k') | KeyCode::Up => {
|
||||||
|
if count > 0 {
|
||||||
|
state.profiles.selected_idx = state
|
||||||
|
.profiles
|
||||||
|
.selected_idx
|
||||||
|
.checked_sub(1)
|
||||||
|
.unwrap_or(count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if count > 0 {
|
||||||
|
state.push_undo();
|
||||||
|
do_load(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') => {
|
||||||
|
if count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let name = state.profiles.profiles[state.profiles.selected_idx].clone();
|
||||||
|
if let Some((pending, _)) = &state.profiles.delete_confirm {
|
||||||
|
if *pending == name {
|
||||||
|
// Second press: confirm delete
|
||||||
|
match profile::delete(&name) {
|
||||||
|
Ok(()) => {
|
||||||
|
state.profiles.refresh();
|
||||||
|
state.set_status(format!("Deleted profile '{}'", name), StatusLevel::Success);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.set_status(format!("Delete failed: {}", e), StatusLevel::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.profiles.delete_confirm = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.profiles.delete_confirm = Some((name.clone(), Instant::now()));
|
||||||
|
state.set_status(
|
||||||
|
format!("Press d again to delete '{}' (3s to cancel)", name),
|
||||||
|
StatusLevel::Info,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
|
let row = event.row;
|
||||||
|
let col = event.column;
|
||||||
|
let (tw, th) = state.terminal_size;
|
||||||
|
|
||||||
|
// Layout in profiles view:
|
||||||
|
// Content area: y=2..th-2
|
||||||
|
// List: y=2, height = content_h - 3 (save row is Length(3))
|
||||||
|
// Save row: y = th - 4, height 3
|
||||||
|
let content_h = th.saturating_sub(3); // th - tab(2) - bottom(1)
|
||||||
|
let save_row_y = 2 + content_h.saturating_sub(3);
|
||||||
|
// List block: border at y=2, items start at y=3
|
||||||
|
let list_items_start = 3u16;
|
||||||
|
|
||||||
|
match event.kind {
|
||||||
|
MouseEventKind::Down(MouseButton::Left) => {
|
||||||
|
if row >= save_row_y {
|
||||||
|
// Clicked in save row
|
||||||
|
let save_btn_x = tw.saturating_sub(16);
|
||||||
|
if col >= save_btn_x {
|
||||||
|
// Save button
|
||||||
|
state.profiles.focused = ProfileField::SaveBtn;
|
||||||
|
do_save(state);
|
||||||
|
} else {
|
||||||
|
// Name input
|
||||||
|
state.profiles.focused = ProfileField::NameInput;
|
||||||
|
}
|
||||||
|
} else if row >= list_items_start {
|
||||||
|
let item_idx = (row - list_items_start) as usize;
|
||||||
|
if item_idx < state.profiles.profiles.len() {
|
||||||
|
state.profiles.focused = ProfileField::List;
|
||||||
|
state.profiles.selected_idx = item_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseEventKind::Down(MouseButton::Right) => {
|
||||||
|
// Right-click on a profile item = load it
|
||||||
|
if row >= list_items_start && row < save_row_y {
|
||||||
|
let item_idx = (row - list_items_start) as usize;
|
||||||
|
if item_idx < state.profiles.profiles.len() {
|
||||||
|
state.profiles.selected_idx = item_idx;
|
||||||
|
state.push_undo();
|
||||||
|
do_load(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollUp => {
|
||||||
|
let count = state.profiles.profiles.len();
|
||||||
|
if count > 0 {
|
||||||
|
state.profiles.selected_idx =
|
||||||
|
state.profiles.selected_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
|
state.profiles.focused = ProfileField::List;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollDown => {
|
||||||
|
let count = state.profiles.profiles.len();
|
||||||
|
if count > 0 {
|
||||||
|
state.profiles.selected_idx = (state.profiles.selected_idx + 1) % count;
|
||||||
|
state.profiles.focused = ProfileField::List;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_name_key(event: KeyEvent, state: &mut AppState) {
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
// Only allow alphanumeric, dash, underscore
|
||||||
|
if c.is_alphanumeric() || c == '-' || c == '_' {
|
||||||
|
state.profiles.new_name.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
state.profiles.new_name.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
do_save(state);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_save(state: &mut AppState) {
|
||||||
|
let name = state.profiles.new_name.trim().to_owned();
|
||||||
|
if name.is_empty() {
|
||||||
|
state.set_status("Enter a profile name first.", StatusLevel::Info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let profile = profile::from_monitors(&name, &state.monitors);
|
||||||
|
match profile::save(&profile) {
|
||||||
|
Ok(()) => {
|
||||||
|
state.profiles.new_name.clear();
|
||||||
|
state.profiles.refresh();
|
||||||
|
state.set_status(format!("Saved profile '{}'", name), StatusLevel::Success);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.set_status(format!("Save failed: {}", e), StatusLevel::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_load(state: &mut AppState) {
|
||||||
|
let name = state.profiles.profiles[state.profiles.selected_idx].clone();
|
||||||
|
match profile::load(&name) {
|
||||||
|
Ok(p) => {
|
||||||
|
profile::apply_to_monitors(&p, &mut state.monitors);
|
||||||
|
state.dirty = true;
|
||||||
|
state.set_status(
|
||||||
|
format!("Loaded profile '{}'. Press [a] to apply.", name),
|
||||||
|
StatusLevel::Success,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.set_status(format!("Load failed: {}", e), StatusLevel::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(0), // profile list
|
||||||
|
Constraint::Length(3), // name input + save button
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_list(f, chunks[0], state);
|
||||||
|
render_save_row(f, chunks[1], state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_list(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let profiles = &state.profiles.profiles;
|
||||||
|
let is_focused = state.profiles.focused == ProfileField::List;
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = if profiles.is_empty() {
|
||||||
|
vec![ListItem::new(Line::from(Span::styled(
|
||||||
|
" No profiles saved yet.",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)))]
|
||||||
|
} else {
|
||||||
|
profiles
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, name)| {
|
||||||
|
let is_selected = i == state.profiles.selected_idx;
|
||||||
|
let pending_delete = state
|
||||||
|
.profiles
|
||||||
|
.delete_confirm
|
||||||
|
.as_ref()
|
||||||
|
.map(|(n, _)| n == name)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let style = if is_selected && is_focused {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
.bg(Color::DarkGray)
|
||||||
|
} else if is_selected {
|
||||||
|
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
let label = if pending_delete {
|
||||||
|
format!(" {} [d again to confirm delete]", name)
|
||||||
|
} else {
|
||||||
|
format!(" {}", name)
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(Line::from(Span::styled(label, style)))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(if is_focused {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
})
|
||||||
|
.title(" Profiles (Enter=load d=delete) ");
|
||||||
|
|
||||||
|
let list = List::new(items).block(block);
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
if !profiles.is_empty() {
|
||||||
|
list_state.select(Some(state.profiles.selected_idx));
|
||||||
|
}
|
||||||
|
f.render_stateful_widget(list, area, &mut list_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_save_row(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let name_focused = state.profiles.focused == ProfileField::NameInput;
|
||||||
|
let save_focused = state.profiles.focused == ProfileField::SaveBtn;
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Min(0), Constraint::Length(16)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Name input
|
||||||
|
let input_display = if name_focused {
|
||||||
|
format!(" Profile name: {}|", state.profiles.new_name)
|
||||||
|
} else {
|
||||||
|
format!(" Profile name: {}", state.profiles.new_name)
|
||||||
|
};
|
||||||
|
let input_style = if name_focused {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(input_display)
|
||||||
|
.style(input_style)
|
||||||
|
.block(Block::default().borders(Borders::ALL).border_style(input_style)),
|
||||||
|
chunks[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save button
|
||||||
|
let save_style = if save_focused {
|
||||||
|
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(" [ Save ] ")
|
||||||
|
.style(save_style)
|
||||||
|
.block(Block::default().borders(Borders::ALL).border_style(save_style)),
|
||||||
|
chunks[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue