From c1366fdfaceaf8e42bba075b3b878b53094332d2 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 11 Aug 2025 11:20:02 -0600 Subject: [PATCH] updates --- monitors/monitor-tui-rs/Cargo.toml | 1 + monitors/monitor-tui-rs/flake.nix | 4 +- monitors/monitor-tui-rs/package.json | 8 -- monitors/monitor-tui-rs/src/main.rs | 195 +++++++++------------------ 4 files changed, 65 insertions(+), 143 deletions(-) delete mode 100644 monitors/monitor-tui-rs/package.json diff --git a/monitors/monitor-tui-rs/Cargo.toml b/monitors/monitor-tui-rs/Cargo.toml index e2c3f7b..f162f04 100644 --- a/monitors/monitor-tui-rs/Cargo.toml +++ b/monitors/monitor-tui-rs/Cargo.toml @@ -8,3 +8,4 @@ regex = "1" which = "6" anyhow = "1" chrono = { version = "0.4", features = ["clock"] } +dialoguer = "0.11" diff --git a/monitors/monitor-tui-rs/flake.nix b/monitors/monitor-tui-rs/flake.nix index 35efc04..22806c4 100644 --- a/monitors/monitor-tui-rs/flake.nix +++ b/monitors/monitor-tui-rs/flake.nix @@ -17,8 +17,6 @@ naersk' = pkgs.callPackage naersk { }; runtimeDeps = with pkgs; [ gnome-monitor-config - dialog - newt xorg.xrandr bash coreutils @@ -49,7 +47,7 @@ default = pkgs.mkShell { buildInputs = with pkgs; [ rustc cargo rustfmt clippy - gnome-monitor-config dialog newt xorg.xrandr bash coreutils + gnome-monitor-config xorg.xrandr bash coreutils ]; }; } diff --git a/monitors/monitor-tui-rs/package.json b/monitors/monitor-tui-rs/package.json deleted file mode 100644 index 8ce5db2..0000000 --- a/monitors/monitor-tui-rs/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "monitor-tui", - "version": "0.1.0", - "description": "Rust rewrite launcher for GNOME Monitor TUI", - "scripts": { - "run": "nix run .#monitor-tui" - } -} diff --git a/monitors/monitor-tui-rs/src/main.rs b/monitors/monitor-tui-rs/src/main.rs index bc448cd..4c2d879 100644 --- a/monitors/monitor-tui-rs/src/main.rs +++ b/monitors/monitor-tui-rs/src/main.rs @@ -7,125 +7,54 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use which::which; +use dialoguer::{theme::ColorfulTheme, Input, Select, MultiSelect}; const APP_NAME: &str = "GNOME Monitor TUI"; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum UIKind { Dialog, Whiptail, Plain } - fn have(cmd: &str) -> bool { which(cmd).is_ok() } -fn detect_ui() -> UIKind { - if have("dialog") { UIKind::Dialog } - else if have("whiptail") { UIKind::Whiptail } - else { UIKind::Plain } +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(); + t.selection_style = t.selection_style.bold(); + t } -fn msgbox(ui: UIKind, text: &str) { - match ui { - UIKind::Dialog => { let _ = Command::new("dialog").arg("--title").arg(APP_NAME).arg("--msgbox").arg(text).arg("10").arg("70").status(); }, - UIKind::Whiptail => { let _ = Command::new("whiptail").arg("--title").arg(APP_NAME).arg("--msgbox").arg(text).arg("10").arg("70").status(); }, - UIKind::Plain => { - println!("\n== {} ==\n{}\n", APP_NAME, text); - let _ = prompt_enter(); - } - } +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(ui: UIKind, prompt: &str, default: &str) -> Result { - Ok(match ui { - UIKind::Dialog => { - let out = Command::new("dialog") - .arg("--title").arg(APP_NAME) - .arg("--stdout") - .arg("--inputbox").arg(prompt).arg("10").arg("70").arg(default) - .output()?; - String::from_utf8_lossy(&out.stdout).trim().to_string() - } - UIKind::Whiptail => { - let out = Command::new("whiptail") - .arg("--title").arg(APP_NAME) - .arg("--inputbox").arg(prompt).arg("10").arg("70").arg(default) - .arg("--output-fd").arg("1") - .output()?; - String::from_utf8_lossy(&out.stdout).trim().to_string() - } - UIKind::Plain => { - print!("{} [{}]: ", prompt, default); - io::stdout().flush()?; - let mut s = String::new(); - io::stdin().read_line(&mut s)?; - let s = s.trim(); - if s.is_empty() { default.to_string() } else { s.to_string() } - } - }) +fn inputbox(prompt: &str, default: &str) -> Result { + Ok(Input::with_theme(&theme()) + .with_prompt(prompt) + .default(default.to_string()) + .interact_text()?) } -fn menu(ui: UIKind, prompt: &str, options: &[(String, String)]) -> Result> { - match ui { - UIKind::Dialog => { - let mut cmd = Command::new("dialog"); - cmd.arg("--title").arg(APP_NAME).arg("--stdout").arg("--menu").arg(prompt).arg("20").arg("70").arg("12"); - for (k, d) in options { cmd.arg(k).arg(d); } - let out = cmd.output()?; - if out.status.success() { - Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_string())) - } else { Ok(None) } - } - UIKind::Whiptail => { - let mut cmd = Command::new("whiptail"); - cmd.arg("--title").arg(APP_NAME).arg("--menu").arg(prompt).arg("20").arg("70").arg("12"); - for (k, d) in options { cmd.arg(k).arg(d); } - cmd.arg("--output-fd").arg("1"); - let out = cmd.output()?; - if out.status.success() { Ok(Some(String::from_utf8_lossy(&out.stdout).trim().to_string())) } else { Ok(None) } - } - UIKind::Plain => { - println!("\n{}", prompt); - for (i, (k, d)) in options.iter().enumerate() { println!(" {}) {}", i+1, d); } - print!("Choose 1-{}: ", options.len()); io::stdout().flush()?; - let mut s = String::new(); io::stdin().read_line(&mut s)?; let s = s.trim(); - if s.is_empty() { return Ok(None); } - if let Ok(ix) = s.parse::() { if ix>=1 && ix<=options.len() { return Ok(Some(options[ix-1].0.clone())); } } - Ok(None) - } - } +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(ui: UIKind, prompt: &str, items: &[(String, String, bool)]) -> Result> { - match ui { - UIKind::Dialog => { - let mut cmd = Command::new("dialog"); - cmd.arg("--title").arg(APP_NAME).arg("--stdout").arg("--checklist").arg(prompt).arg("20").arg("70").arg("12"); - for (k, d, on) in items { cmd.arg(k).arg(d).arg(if *on { "ON" } else { "OFF" }); } - let out = cmd.output()?; - if out.status.success() { - let s = String::from_utf8_lossy(&out.stdout); - Ok(parse_selected(&s)) - } else { Ok(vec![]) } - } - UIKind::Whiptail => { - let mut cmd = Command::new("whiptail"); - cmd.arg("--title").arg(APP_NAME).arg("--checklist").arg(prompt).arg("20").arg("70").arg("12"); - for (k, d, on) in items { cmd.arg(k).arg(d).arg(if *on { "ON" } else { "OFF" }); } - cmd.arg("--output-fd").arg("1"); - let out = cmd.output()?; - if out.status.success() { Ok(parse_selected(&String::from_utf8_lossy(&out.stdout))) } else { Ok(vec![]) } - } - UIKind::Plain => { - println!("\n{}", prompt); - for (i, (k, d, on)) in items.iter().enumerate() { - println!(" [{}] {} {}", if *on { "x" } else { " " }, i+1, format!("{} {}", k, d)); - } - print!("Enter numbers to select (e.g. 1 3): "); io::stdout().flush()?; - let mut s = String::new(); io::stdin().read_line(&mut s)?; let s = s.trim(); - let mut out = vec![]; - for tok in s.split_whitespace() { - if let Ok(ix) = tok.parse::() { if ix>=1 && ix<=items.len() { out.push(items[ix-1].0.clone()); } } - } - Ok(out) - } - } +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 mut ms = MultiSelect::with_theme(&theme()); + ms.with_prompt(prompt).items(&labels); + // Set defaults + ms.defaults(&defaults); + let chosen = ms.interact()?; + Ok(chosen.into_iter().map(|i| items[i].0.clone()).collect()) } fn parse_selected(s: &str) -> Vec { @@ -150,8 +79,7 @@ fn discover_connectors() -> Vec { } if found.is_empty() { // Fallback: ask user - let ui = detect_ui(); - let manual = inputbox(ui, "Enter connector names (space-separated):", "DP-3 DP-10 eDP-1").unwrap_or_default(); + 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() @@ -252,15 +180,19 @@ fn snapshot_save(label: &str, cmdline: &str) -> Result { Ok(file) } -fn snapshot_pick(ui: UIKind) -> 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![] }; entries.sort(); entries.retain(|p| p.extension().map(|e| e == "sh").unwrap_or(false)); - if entries.is_empty() { msgbox(ui, "No snapshots saved yet."); return Ok(None); } - let mut opts: Vec<(String, String)> = Vec::new(); - for p in &entries { let base = p.file_name().unwrap().to_string_lossy().to_string(); opts.push((base.clone(), "apply/delete".into())); } - if let Some(sel) = menu(ui, "Snapshots:", &opts)? { + 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)) + }) + .collect(); + if let Some(sel) = menu("Snapshots:", &opts)? { return Ok(Some(dir.join(sel))); } Ok(None) @@ -269,23 +201,22 @@ fn snapshot_pick(ui: UIKind) -> Result> { fn prompt_enter() -> Result<()> { let mut s = String::new(); io::stdin().read_line(&mut s)?; Ok(()) } fn main() -> Result<()> { - let ui = detect_ui(); if !have("gnome-monitor-config") { - msgbox(ui, "gnome-monitor-config is required (Wayland). Please install it (part of GNOME). Aborting."); + msgbox("gnome-monitor-config is required (Wayland). Please install it (part of GNOME). Aborting."); return Ok(()); } loop { - let choice = menu(ui, "Choose an action:", &[ + 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()), ])?; match choice.as_deref() { - Some("new") => new_layout(ui)?, - Some("apply") => { if let Some(p) = snapshot_pick(ui)? { let _ = Command::new("bash").arg(p).status(); } }, - Some("delete") => { if let Some(p) = snapshot_pick(ui)? { let _ = fs::remove_file(&p); msgbox(ui, &format!("Deleted snapshot: {}", p.file_name().unwrap().to_string_lossy())); } }, + 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("quit") | None => break, _ => {} } @@ -293,27 +224,27 @@ fn main() -> Result<()> { Ok(()) } -fn new_layout(ui: UIKind) -> 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)); } - let picked = checklist(ui, "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(ui, "You must select at least one connector."); return Ok(()); } + 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(()); } // Summary let mut summary = String::new(); for c in &conns { summary.push_str(&format!("{} {}\n", c, describe_connector(c))); } - msgbox(ui, &format!("Detected connectors:\n{}", summary)); + msgbox(&format!("Detected connectors:\n{}", summary)); - let place = match menu(ui, "Where do you want to place the OTHER monitors relative to the mirrored group?", &[ + 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(ui, "Pixel offset for Y (distance from mirrored group). Example: 2160", "2160")?.parse().unwrap_or(2160); - let offx: i32 = inputbox(ui, "Pixel offset for X. Example: 0", "0")?.parse().unwrap_or(0); + 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 pick_set: BTreeSet = picked.iter().cloned().collect(); let mut others: Vec = conns.iter().filter(|c| !pick_set.contains(*c)).cloned().collect(); @@ -321,7 +252,7 @@ fn new_layout(ui: UIKind) -> Result<()> { 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)); } - let picked2 = checklist(ui, &format!("Select which of the remaining connectors to include (they will be placed {} the group):", place), &cl2)?; + let picked2 = checklist(&format!("Select which of the remaining connectors to include (they will be placed {} the group):", place), &cl2)?; others = picked2; } @@ -329,17 +260,17 @@ fn new_layout(ui: UIKind) -> Result<()> { let status = Command::new("gnome-monitor-config").args(&args).status()?; let cmdline = args_to_shell("gnome-monitor-config", &args); if status.success() { - msgbox(ui, &format!("Applied:\n{}", cmdline)); - if let Some(ans) = menu(ui, "Snapshot this working layout?", &[("yes".into(), "Save snapshot".into()), ("no".into(), "Skip".into())])? { + msgbox(&format!("Applied:\n{}", cmdline)); + 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 label = inputbox(ui, "Snapshot label:", &default)?; + let label = inputbox("Snapshot label:", &default)?; let file = snapshot_save(&label, &cmdline)?; - msgbox(ui, &format!("Saved snapshot: {}", file.display())); + msgbox(&format!("Saved snapshot: {}", file.display())); } } } else { - msgbox(ui, &format!("Failed to apply the layout.\nCommand was:\n{}", cmdline)); + msgbox(&format!("Failed to apply the layout.\nCommand was:\n{}", cmdline)); } Ok(())