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

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],
);
}