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