From 47f72bc819abad5a7bfcd44129fd4cf85327dd93 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 11 Aug 2025 11:44:56 -0600 Subject: [PATCH] monitors tui --- monitors/monitor-tui-rs/src/main.rs | 441 +++++++++++++++++------- monitors/monitor-tui-rs/src/ui_utils.rs | 68 ++++ 2 files changed, 383 insertions(+), 126 deletions(-) create mode 100644 monitors/monitor-tui-rs/src/ui_utils.rs diff --git a/monitors/monitor-tui-rs/src/main.rs b/monitors/monitor-tui-rs/src/main.rs index 602ebdb..ad3030c 100644 --- a/monitors/monitor-tui-rs/src/main.rs +++ b/monitors/monitor-tui-rs/src/main.rs @@ -3,73 +3,15 @@ use regex::Regex; use std::collections::{BTreeSet, HashMap}; use std::env; use std::fs; -use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use which::which; -use dialoguer::{theme::ColorfulTheme, Input, Select, MultiSelect}; -use console::style; -const APP_NAME: &str = "GNOME Monitor TUI"; +mod ui_utils; +use ui_utils::{checklist, inputbox, menu, msgbox}; -fn have(cmd: &str) -> bool { which(cmd).is_ok() } - -fn theme() -> ColorfulTheme { - let mut t = ColorfulTheme::default(); - // Slightly more vivid selection and success colors for a modern feel - t.values_style = t.values_style.bold(); - t.active_item_style = t.active_item_style.bold(); - // Customize prefixes for a cleaner, modern aesthetic - t.prompt_prefix = style("❯".to_string()); - t.success_prefix = style("✔".to_string()); - t.error_prefix = style("✘".to_string()); - t.active_item_prefix = style("➤".to_string()); - t.inactive_item_prefix = style(" ".to_string()); - t.checked_item_prefix = style("◉".to_string()); - t.unchecked_item_prefix = style("○".to_string()); - t.picked_item_prefix = style("⭐".to_string()); - t.unpicked_item_prefix = style(" ".to_string()); - t -} - -fn msgbox(text: &str) { - let bar = "═".repeat(64); - println!("\n╔{}╗\n║ {:<62} ║\n╚{}╝\n", bar, APP_NAME, bar); - println!("{}\n", text); - let _ = prompt_enter(); -} - -fn inputbox(prompt: &str, default: &str) -> Result { - Ok(Input::with_theme(&theme()) - .with_prompt(prompt) - .default(default.to_string()) - .interact_text()?) -} - -fn menu(prompt: &str, options: &[(String, String)]) -> Result> { - let items: Vec = options.iter().map(|(_, d)| d.clone()).collect(); - let idx = Select::with_theme(&theme()) - .with_prompt(prompt) - .items(&items) - .default(0) - .interact_opt()?; - Ok(idx.map(|i| options[i].0.clone())) -} - -fn checklist(prompt: &str, items: &[(String, String, bool)]) -> Result> { - let labels: Vec = items.iter().map(|(k, d, _)| format!("{} {}", k, d)).collect(); - let defaults: Vec = items.iter().map(|(_, _, on)| *on).collect(); - let chosen = MultiSelect::with_theme(&theme()) - .with_prompt(prompt) - .items(&labels) - .defaults(&defaults) - .interact()?; - Ok(chosen.into_iter().map(|i| items[i].0.clone()).collect()) -} - -fn parse_selected(s: &str) -> Vec { - // dialog/whiptail may quote items. Remove quotes and split - s.replace('"', "").split_whitespace().map(|t| t.to_string()).collect() +fn have(cmd: &str) -> bool { + which(cmd).is_ok() } fn discover_connectors() -> Vec { @@ -78,19 +20,28 @@ fn discover_connectors() -> Vec { if have("gnome-monitor-config") { if let Ok(out) = Command::new("gnome-monitor-config").arg("list").output() { let s = String::from_utf8_lossy(&out.stdout); - for cap in re.captures_iter(&s) { found.insert(cap.get(0).unwrap().as_str().to_string()); } + for cap in re.captures_iter(&s) { + found.insert(cap.get(0).unwrap().as_str().to_string()); + } } } if found.is_empty() && have("xrandr") { if let Ok(out) = Command::new("xrandr").arg("--listmonitors").output() { let s = String::from_utf8_lossy(&out.stdout); - for cap in re.captures_iter(&s) { found.insert(cap.get(0).unwrap().as_str().to_string()); } + for cap in re.captures_iter(&s) { + found.insert(cap.get(0).unwrap().as_str().to_string()); + } } } if found.is_empty() { - // Fallback: ask user - let manual = inputbox("Enter connector names (space-separated):", "DP-3 DP-10 eDP-1").unwrap_or_default(); - for t in manual.split_whitespace() { found.insert(t.to_string()); } + let manual = inputbox( + "Enter connector names (space-separated):", + "DP-3 DP-10 eDP-1", + ) + .unwrap_or_default(); + for t in manual.split_whitespace() { + found.insert(t.to_string()); + } } found.into_iter().collect() } @@ -102,11 +53,20 @@ fn describe_connector(conn: &str) -> String { for line in s.lines() { if line.starts_with(conn) { let status = line.split_whitespace().nth(1).unwrap_or(""); - let primary = if line.contains(" primary ") { " primary" } else { "" }; + let primary = if line.contains(" primary ") { + " primary" + } else { + "" + }; let re = Regex::new(r"[0-9]{3,}x[0-9]{3,}\+[0-9]+\+[0-9]+").unwrap(); - let respos = re.find(line).map(|m| format!(" {}", m.as_str())).unwrap_or_default(); + let respos = re + .find(line) + .map(|m| format!(" {}", m.as_str())) + .unwrap_or_default(); let mut desc = format!("[{}{}{}]", status, primary, respos); - if conn.starts_with("eDP-") { desc.push_str(" internal"); } + if conn.starts_with("eDP-") { + desc.push_str(" internal"); + } return desc; } } @@ -118,49 +78,199 @@ fn describe_connector(conn: &str) -> String { if s.contains(conn) { let re = Regex::new(r"[0-9]{3,}x[0-9]{3,}@?[0-9.]*").unwrap(); let wh = re.find(&s).map(|m| m.as_str().to_string()); - let mut desc = match wh { Some(w) => format!("[present {}]", w), None => "[present]".to_string() }; - if conn.starts_with("eDP-") { desc.push_str(" internal"); } + let mut desc = match wh { + Some(w) => format!("[present {}]", w), + None => "[present]".to_string(), + }; + if conn.starts_with("eDP-") { + desc.push_str(" internal"); + } return desc; } } } let mut desc = "[present]".to_string(); - if conn.starts_with("eDP-") { desc.push_str(" internal"); } + if conn.starts_with("eDP-") { + desc.push_str(" internal"); + } desc } -fn build_command_args(mirror_group: &[String], placement: &str, offy: i32, offx: i32, others: &[String]) -> Vec { - let mut args: Vec = vec!["set".into()]; - // First logical monitor: PRIMARY group at 0,0 - args.push("-Lp".into()); - for m in mirror_group { args.push("-M".into()); args.push(m.clone()); } - args.push("-x".into()); args.push("0".into()); - args.push("-y".into()); args.push("0".into()); +// Detect current resolution (width, height) for a connector, best-effort. +fn connector_size(conn: &str) -> Option<(i32, i32)> { + // Try xrandr first (has precise current mode with +pos) + if have("xrandr") { + if let Ok(out) = Command::new("xrandr").arg("--query").output() { + let s = String::from_utf8_lossy(&out.stdout); + for line in s.lines() { + if line.starts_with(conn) { + let re = Regex::new(r"([0-9]{3,})x([0-9]{3,})").ok()?; + if let Some(c) = re.captures(line) { + let w: i32 = c.get(1)?.as_str().parse().ok()?; + let h: i32 = c.get(2)?.as_str().parse().ok()?; + return Some((w, h)); + } + } + } + } + } + // Fallback: parse gnome-monitor-config list near the connector name + if have("gnome-monitor-config") { + if let Ok(out) = Command::new("gnome-monitor-config").arg("list").output() { + let s = String::from_utf8_lossy(&out.stdout); + if let Some(idx) = s.find(conn) { + let end = (idx + 200).min(s.len()); + let window = &s[idx..end]; + if let Some(c) = Regex::new(r"([0-9]{3,})x([0-9]{3,})") + .ok() + .and_then(|re| re.captures(window)) + { + let w: i32 = c.get(1)?.as_str().parse().ok()?; + let h: i32 = c.get(2)?.as_str().parse().ok()?; + return Some((w, h)); + } + } + } + } + None +} +fn group_size(conns: &[String]) -> (i32, i32) { + let mut max_w = 1920i32; + let mut max_h = 1080i32; + for c in conns { + if let Some((w, h)) = connector_size(c) { + if w > max_w { + max_w = w; + } + if h > max_h { + max_h = h; + } + } + } + (max_w, max_h) +} + +fn build_command_args_auto( + mirror_group: &[String], + placement: &str, + others: &[String], +) -> Vec { + let (gw, gh) = group_size(mirror_group); + let mut args: Vec = vec!["set".into()]; + // Primary logical monitor (mirrored group) at 0,0 + args.push("-Lp".into()); + for m in mirror_group { + args.push("-M".into()); + args.push(m.clone()); + } + args.push("-x".into()); + args.push("0".into()); + args.push("-y".into()); + args.push("0".into()); + + // Tile the remaining monitors relative to the group size + let mut cur_x = 0i32; + let mut cur_y = 0i32; for o in others { + let (ow, oh) = connector_size(o).unwrap_or((1920, 1080)); let (x, y) = match placement { - "below" => (offx, offy), - "above" => (offx, -offy), - "right" => (offx, offy), - "left" => (-offx, offy), - _ => (offx, offy), + "right" => { + let pos = (gw, cur_y); + cur_y += oh; + pos + } + "left" => { + let pos = (-ow, cur_y); + cur_y += oh; + pos + } + "below" => { + let pos = (cur_x, gh); + cur_x += ow; + pos + } + "above" => { + let pos = (cur_x, -oh); + cur_x += ow; + pos + } + _ => (gw, 0), }; args.push("-L".into()); - args.push("-M".into()); args.push(o.clone()); - args.push("-x".into()); args.push(x.to_string()); - args.push("-y".into()); args.push(y.to_string()); + args.push("-M".into()); + args.push(o.clone()); + args.push("-x".into()); + args.push(x.to_string()); + args.push("-y".into()); + args.push(y.to_string()); } args } +// Build args for the CURRENT layout by parsing xrandr --query (best-effort) +fn current_layout_args() -> Option> { + if !have("xrandr") { + return None; + } + let out = Command::new("xrandr").arg("--query").output().ok()?; + let s = String::from_utf8_lossy(&out.stdout); + let geom_re = Regex::new(r"([0-9]{3,})x([0-9]{3,})\+([0-9]+)\+([0-9]+)").ok()?; + let mut entries: Vec<(String, i32, i32, bool)> = Vec::new(); + for line in s.lines() { + let mut parts = line.split_whitespace(); + let name = match parts.next() { + Some(n) => n, + None => continue, + }; + if !line.contains(" connected ") { + continue; + } + if let Some(cap) = geom_re.captures(line) { + let x: i32 = cap.get(3)?.as_str().parse().ok()?; + let y: i32 = cap.get(4)?.as_str().parse().ok()?; + let primary = line.contains(" primary "); + entries.push((name.to_string(), x, y, primary)); + } + } + if entries.is_empty() { + return None; + } + // primary first + entries.sort_by_key(|e| (!e.3, e.1, e.2)); + let mut args: Vec = vec!["set".into()]; + let mut first = true; + for (name, x, y, _primary) in entries { + if first { + args.push("-Lp".into()); + first = false; + } else { + args.push("-L".into()); + } + args.push("-M".into()); + args.push(name); + args.push("-x".into()); + args.push(x.to_string()); + args.push("-y".into()); + args.push(y.to_string()); + } + Some(args) +} + fn args_to_shell(cmd: &str, args: &[String]) -> String { - let mut s = String::new(); s.push_str(cmd); - for a in args { s.push(' '); s.push_str(&shell_escape(a)); } + let mut s = String::new(); + s.push_str(cmd); + for a in args { + s.push(' '); + s.push_str(&shell_escape(a)); + } s } fn shell_escape(s: &str) -> String { - if s.chars().all(|c| c.is_ascii_alphanumeric() || "-_/.:@".contains(c)) { + if s.chars() + .all(|c| c.is_ascii_alphanumeric() || "-_/.:@".contains(c)) + { s.to_string() } else { let escaped = s.replace('\'', "'\\''"); @@ -169,20 +279,27 @@ fn shell_escape(s: &str) -> String { } fn snapshot_dir() -> PathBuf { - let base = env::var("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|_| dirs_home_config()); + let base = env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| dirs_home_config()); base.join("monitor-tui") } fn dirs_home_config() -> PathBuf { - let home = env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")); + let home = env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")); home.join(".config") } fn snapshot_save(label: &str, cmdline: &str) -> Result { - let dir = snapshot_dir(); fs::create_dir_all(&dir)?; + let dir = snapshot_dir(); + fs::create_dir_all(&dir)?; let mut name = label.replace(|c: char| c.is_whitespace(), "_"); name.retain(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'); - if name.is_empty() { name = format!("snapshot_{}", chrono::Local::now().format("%Y%m%d_%H%M%S")); } + if name.is_empty() { + name = format!("snapshot_{}", chrono::Local::now().format("%Y%m%d_%H%M%S")); + } let file = dir.join(format!("{}.sh", name)); let content = format!("#!/usr/bin/env bash\nset -euo pipefail\n{}\n", cmdline); fs::write(&file, content)?; @@ -192,11 +309,21 @@ fn snapshot_save(label: &str, cmdline: &str) -> Result { fn snapshot_pick() -> Result> { let dir = snapshot_dir(); - let mut entries: Vec = if dir.exists() { fs::read_dir(&dir)?.filter_map(|e| e.ok().map(|e| e.path())).collect() } else { vec![] }; + let mut entries: Vec = if dir.exists() { + fs::read_dir(&dir)? + .filter_map(|e| e.ok().map(|e| e.path())) + .collect() + } else { + vec![] + }; entries.sort(); entries.retain(|p| p.extension().map(|e| e == "sh").unwrap_or(false)); - if entries.is_empty() { msgbox("No snapshots saved yet."); return Ok(None); } - let opts: Vec<(String, String)> = entries.iter() + if entries.is_empty() { + msgbox("No snapshots saved yet."); + return Ok(None); + } + let opts: Vec<(String, String)> = entries + .iter() .map(|p| { let base = p.file_name().unwrap().to_string_lossy().to_string(); (base.clone(), format!("{}", base)) @@ -208,8 +335,6 @@ fn snapshot_pick() -> Result> { Ok(None) } -fn prompt_enter() -> Result<()> { let mut s = String::new(); io::stdin().read_line(&mut s)?; Ok(()) } - fn main() -> Result<()> { if !have("gnome-monitor-config") { msgbox("gnome-monitor-config is required (Wayland). Please install it (part of GNOME). Aborting."); @@ -217,16 +342,48 @@ fn main() -> Result<()> { } loop { - let choice = menu("Choose an action:", &[ - ("new".into(), "Create & apply a new layout".into()), - ("apply".into(), "Apply a saved snapshot".into()), - ("delete".into(), "Delete a saved snapshot".into()), - ("quit".into(), "Quit".into()), - ])?; + let choice = menu( + "Choose an action:", + &[ + ("new".into(), "Create & apply a new layout".into()), + ("apply".into(), "Apply a saved snapshot".into()), + ("delete".into(), "Delete a saved snapshot".into()), + ("snapshot".into(), "Save CURRENT layout as snapshot".into()), + ("quit".into(), "Quit".into()), + ], + )?; match choice.as_deref() { Some("new") => new_layout()?, - Some("apply") => { if let Some(p) = snapshot_pick()? { let _ = Command::new("bash").arg(p).status(); } }, - Some("delete") => { if let Some(p) = snapshot_pick()? { let _ = fs::remove_file(&p); msgbox(&format!("Deleted snapshot: {}", p.file_name().unwrap().to_string_lossy())); } }, + Some("apply") => { + if let Some(p) = snapshot_pick()? { + let _ = Command::new("bash").arg(p).status(); + } + } + Some("delete") => { + if let Some(p) = snapshot_pick()? { + let _ = fs::remove_file(&p); + msgbox(&format!( + "Deleted snapshot: {}", + p.file_name().unwrap().to_string_lossy() + )); + } + } + Some("snapshot") => match current_layout_args() { + Some(args) => { + let default = + format!("current_{}", chrono::Local::now().format("%Y%m%d_%H%M%S")); + let label = inputbox("Snapshot label:", &default)?; + let cmdline = args_to_shell("gnome-monitor-config", &args); + let file = snapshot_save(&label, &cmdline)?; + msgbox(&format!( + "Saved snapshot of CURRENT layout: {}", + file.display() + )); + } + None => { + msgbox("Could not detect current layout (xrandr geometry not available).\nTry applying a layout via this tool first, then snapshot."); + } + }, Some("quit") | None => break, _ => {} } @@ -237,50 +394,82 @@ fn main() -> Result<()> { fn new_layout() -> Result<()> { let conns = discover_connectors(); let mut cl_args: Vec<(String, String, bool)> = Vec::new(); - for c in &conns { cl_args.push((c.clone(), describe_connector(c), false)); } + for c in &conns { + cl_args.push((c.clone(), describe_connector(c), false)); + } let picked = checklist("Pick one or more connectors to MIRROR as a single logical monitor (these will be your 'big' display):", &cl_args)?; - if picked.is_empty() { msgbox("You must select at least one connector."); return Ok(()); } + if picked.is_empty() { + msgbox("You must select at least one connector."); + return Ok(()); + } // Summary let mut summary = String::new(); - for c in &conns { summary.push_str(&format!("{} {}\n", c, describe_connector(c))); } + for c in &conns { + summary.push_str(&format!("{} {}\n", c, describe_connector(c))); + } msgbox(&format!("Detected connectors:\n{}", summary)); - let place = match menu("Where do you want to place the OTHER monitors relative to the mirrored group?", &[ - ("below".into(), "Below (typical laptop-under-desktop)".into()), - ("above".into(), "Above".into()), - ("left".into(), "Left".into()), - ("right".into(), "Right".into()), - ])? { Some(p) => p, None => return Ok(()) }; - - let offy: i32 = inputbox("Pixel offset for Y (distance from mirrored group). Example: 2160", "2160")?.parse().unwrap_or(2160); - let offx: i32 = inputbox("Pixel offset for X. Example: 0", "0")?.parse().unwrap_or(0); + let place = match menu( + "Where do you want to place the OTHER monitors relative to the mirrored group?", + &[ + ( + "below".into(), + "Below (typical laptop-under-desktop)".into(), + ), + ("above".into(), "Above".into()), + ("left".into(), "Left".into()), + ("right".into(), "Right".into()), + ], + )? { + Some(p) => p.to_string(), + None => return Ok(()), + }; let pick_set: BTreeSet = picked.iter().cloned().collect(); - let mut others: Vec = conns.iter().filter(|c| !pick_set.contains(*c)).cloned().collect(); + let mut others: Vec = conns + .iter() + .filter(|c| !pick_set.contains(*c)) + .cloned() + .collect(); if !others.is_empty() { let mut cl2: Vec<(String, String, bool)> = Vec::new(); - for o in &others { cl2.push((o.clone(), describe_connector(o), true)); } + for o in &others { + cl2.push((o.clone(), describe_connector(o), true)); + } let picked2 = checklist(&format!("Select which of the remaining connectors to include (they will be placed {} the group):", place), &cl2)?; others = picked2; } - let args = build_command_args(&picked, &place, offy, offx, &others); + // Auto-calculate offsets based on monitor sizes and placement + let args = build_command_args_auto(&picked, &place, &others); let status = Command::new("gnome-monitor-config").args(&args).status()?; let cmdline = args_to_shell("gnome-monitor-config", &args); if status.success() { msgbox(&format!("Applied:\n{}", cmdline)); - if let Some(ans) = menu("Snapshot this working layout?", &[("yes".into(), "Save snapshot".into()), ("no".into(), "Skip".into())])? { + if let Some(ans) = menu( + "Snapshot this working layout?", + &[ + ("yes".into(), "Save snapshot".into()), + ("no".into(), "Skip".into()), + ], + )? { if ans == "yes" { - let default = format!("mirrored_group_{}", chrono::Local::now().format("%Y%m%d_%H%M%S")); + let default = format!( + "mirrored_group_{}", + chrono::Local::now().format("%Y%m%d_%H%M%S") + ); let label = inputbox("Snapshot label:", &default)?; let file = snapshot_save(&label, &cmdline)?; msgbox(&format!("Saved snapshot: {}", file.display())); } } } else { - msgbox(&format!("Failed to apply the layout.\nCommand was:\n{}", cmdline)); + msgbox(&format!( + "Failed to apply the layout.\nCommand was:\n{}", + cmdline + )); } Ok(()) diff --git a/monitors/monitor-tui-rs/src/ui_utils.rs b/monitors/monitor-tui-rs/src/ui_utils.rs new file mode 100644 index 0000000..4a3d58f --- /dev/null +++ b/monitors/monitor-tui-rs/src/ui_utils.rs @@ -0,0 +1,68 @@ +use anyhow::Result; +use console::style; +use dialoguer::{theme::ColorfulTheme, Input, MultiSelect, Select}; +use std::io; + +const APP_NAME: &str = "GNOME Monitor TUI"; + +fn theme() -> ColorfulTheme { + let mut t = ColorfulTheme::default(); + // Slightly more vivid selection and success colors for a modern feel + t.values_style = t.values_style.bold(); + t.active_item_style = t.active_item_style.bold(); + // Customize prefixes for a cleaner, modern aesthetic + t.prompt_prefix = style("❯".to_string()); + t.success_prefix = style("✔".to_string()); + t.error_prefix = style("✘".to_string()); + t.active_item_prefix = style("➤".to_string()); + t.inactive_item_prefix = style(" ".to_string()); + t.checked_item_prefix = style("◉".to_string()); + t.unchecked_item_prefix = style("○".to_string()); + t.picked_item_prefix = style("⭐".to_string()); + t.unpicked_item_prefix = style(" ".to_string()); + t +} + +pub fn msgbox(text: &str) { + let bar = "═".repeat(64); + println!("\n╔{}╗\n║ {:<62} ║\n╚{}╝\n", bar, APP_NAME, bar); + println!("{}\n", text); + let _ = prompt_enter(); +} + +pub fn inputbox(prompt: &str, default: &str) -> Result { + Ok(Input::with_theme(&theme()) + .with_prompt(prompt) + .default(default.to_string()) + .interact_text()?) +} + +pub fn menu(prompt: &str, options: &[(String, String)]) -> Result> { + let items: Vec = options.iter().map(|(_, d)| d.clone()).collect(); + let idx = Select::with_theme(&theme()) + .with_prompt(prompt) + .items(&items) + .default(0) + .interact_opt()?; + Ok(idx.map(|i| options[i].0.clone())) +} + +pub fn checklist(prompt: &str, items: &[(String, String, bool)]) -> Result> { + let labels: Vec = items + .iter() + .map(|(k, d, _)| format!("{} {}", k, d)) + .collect(); + let defaults: Vec = items.iter().map(|(_, _, on)| *on).collect(); + let chosen = MultiSelect::with_theme(&theme()) + .with_prompt(prompt) + .items(&labels) + .defaults(&defaults) + .interact()?; + Ok(chosen.into_iter().map(|i| items[i].0.clone()).collect()) +} + +pub fn prompt_enter() -> Result<()> { + let mut s = String::new(); + io::stdin().read_line(&mut s)?; + Ok(()) +}