Initial commit

This commit is contained in:
Breadway 2026-07-02 20:59:16 +08:00
commit c5136331b4
16 changed files with 4556 additions and 0 deletions

31
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load diff

26
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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],
);
}