diff --git a/monitors/monitor-tui-rs/Cargo.toml b/monitors/monitor-tui-rs/Cargo.toml new file mode 100644 index 0000000..e2c3f7b --- /dev/null +++ b/monitors/monitor-tui-rs/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "monitor-tui" +version = "0.1.0" +edition = "2021" + +[dependencies] +regex = "1" +which = "6" +anyhow = "1" +chrono = { version = "0.4", features = ["clock"] } diff --git a/monitors/monitor-tui-rs/flake.nix b/monitors/monitor-tui-rs/flake.nix new file mode 100644 index 0000000..35efc04 --- /dev/null +++ b/monitors/monitor-tui-rs/flake.nix @@ -0,0 +1,58 @@ +{ + description = "GNOME Monitor TUI in Rust with runtime deps"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + naersk.url = "github:nix-community/naersk"; + }; + + outputs = { self, nixpkgs, naersk }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system); + in { + packages = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + naersk' = pkgs.callPackage naersk { }; + runtimeDeps = with pkgs; [ + gnome-monitor-config + dialog + newt + xorg.xrandr + bash + coreutils + ]; + in { + default = naersk'.buildPackage { + pname = "monitor-tui"; + version = "0.1.0"; + src = ./.; + nativeBuildInputs = [ pkgs.makeWrapper ]; + buildInputs = [ ]; + postInstall = '' + wrapProgram $out/bin/monitor-tui \ + --prefix PATH : ${pkgs.lib.makeBinPath runtimeDeps} + ''; + }; + }); + + apps = forAllSystems (system: { + default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/monitor-tui"; + }; + }); + + devShells = forAllSystems (system: + let pkgs = import nixpkgs { inherit system; }; in { + default = pkgs.mkShell { + buildInputs = with pkgs; [ + rustc cargo rustfmt clippy + gnome-monitor-config dialog newt xorg.xrandr bash coreutils + ]; + }; + } + ); + }; +} diff --git a/monitors/monitor-tui-rs/package.json b/monitors/monitor-tui-rs/package.json new file mode 100644 index 0000000..8ce5db2 --- /dev/null +++ b/monitors/monitor-tui-rs/package.json @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000..bc448cd --- /dev/null +++ b/monitors/monitor-tui-rs/src/main.rs @@ -0,0 +1,346 @@ +use anyhow::{anyhow, Context, Result}; +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; + +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 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 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 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 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 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 discover_connectors() -> Vec { + let mut found = BTreeSet::new(); + let re = Regex::new(r"\b(HDMI|DP|eDP|VGA|DVI|USB-C)(-[0-9]+)\b").unwrap(); + 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()); } + } + } + 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()); } + } + } + 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(); + for t in manual.split_whitespace() { found.insert(t.to_string()); } + } + found.into_iter().collect() +} + +fn describe_connector(conn: &str) -> String { + 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 status = line.split_whitespace().nth(1).unwrap_or(""); + 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 mut desc = format!("[{}{}{}]", status, primary, respos); + if conn.starts_with("eDP-") { desc.push_str(" internal"); } + return desc; + } + } + } + } + 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 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"); } + return desc; + } + } + } + let mut desc = "[present]".to_string(); + 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()); + + for o in others { + let (x, y) = match placement { + "below" => (offx, offy), + "above" => (offx, -offy), + "right" => (offx, offy), + "left" => (-offx, offy), + _ => (offx, offy), + }; + 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 +} + +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)); } + s +} + +fn shell_escape(s: &str) -> String { + if s.chars().all(|c| c.is_ascii_alphanumeric() || "-_/.:@".contains(c)) { + s.to_string() + } else { + let escaped = s.replace('\'', "'\\''"); + format!("'{}'", escaped) + } +} + +fn snapshot_dir() -> PathBuf { + 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(".")); + home.join(".config") +} + +fn snapshot_save(label: &str, cmdline: &str) -> Result { + 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")); } + let file = dir.join(format!("{}.sh", name)); + let content = format!("#!/usr/bin/env bash\nset -euo pipefail\n{}\n", cmdline); + fs::write(&file, content)?; + let _ = Command::new("chmod").arg("+x").arg(&file).status(); + Ok(file) +} + +fn snapshot_pick(ui: UIKind) -> 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)? { + return Ok(Some(dir.join(sel))); + } + Ok(None) +} + +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."); + return Ok(()); + } + + loop { + let choice = menu(ui, "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("quit") | None => break, + _ => {} + } + } + Ok(()) +} + +fn new_layout(ui: UIKind) -> 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(()); } + + // 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)); + + let place = match menu(ui, "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 pick_set: BTreeSet = picked.iter().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)); } + let picked2 = checklist(ui, &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); + 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())])? { + 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 file = snapshot_save(&label, &cmdline)?; + msgbox(ui, &format!("Saved snapshot: {}", file.display())); + } + } + } else { + msgbox(ui, &format!("Failed to apply the layout.\nCommand was:\n{}", cmdline)); + } + + Ok(()) +} diff --git a/monitors/monitor-tui.sh b/monitors/monitor-tui.sh new file mode 100755 index 0000000..3802713 --- /dev/null +++ b/monitors/monitor-tui.sh @@ -0,0 +1,294 @@ +#! /usr/bin/env nix-shell +#! nix-shell -i bash -p gnome-monitor-config dialog newt bash + +set -euo pipefail + +# === Config === +APP_NAME="GNOME Monitor TUI" +SNAP_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/monitor-tui" +mkdir -p "$SNAP_DIR" + +# === UI helpers (dialog -> whiptail -> plain) === +have() { command -v "$1" >/dev/null 2>&1; } +UI="" +if have dialog; then UI="dialog" +elif have whiptail; then UI="whiptail" +else UI="plain"; fi + +# Provide a short, useful description for a connector (status, primary, resolution) +describe_connector() { + local c="$1" + local desc="" + if have xrandr; then + local line + line="$(xrandr --query 2>/dev/null | grep -E "^${c}\\b" | head -n1 || true)" + if [ -n "$line" ]; then + local status + status="$(awk '{print $2}' <<<"$line")" + local primary="" + grep -q " primary " <<<"$line" && primary=" primary" + local respos + respos="$(grep -Eo '[0-9]{3,}x[0-9]{3,}\+[0-9]+\+[0-9]+' <<<"$line" | head -n1)" + [ -n "$respos" ] && respos=" $respos" + desc="[$status$primary$respos]" + fi + fi + if [ -z "$desc" ] && have gnome-monitor-config; then + local gline + gline="$(gnome-monitor-config list 2>/dev/null | grep -E "\\b${c}\\b" | head -n1 || true)" + if [ -n "$gline" ]; then + local wh + wh="$(echo "$gline" | grep -Eo '[0-9]{3,}x[0-9]{3,}@?[0-9.]*' | head -n1)" + if [ -n "$wh" ]; then desc="[present $wh]"; else desc="[present]"; fi + fi + fi + [ -z "$desc" ] && desc="[present]" + case "$c" in eDP-*) desc="$desc internal" ;; esac + echo "$desc" +} + +title() { printf "%s\n" "$1"; } +msgbox() { + local text="$1" + case "$UI" in + dialog) dialog --title "$APP_NAME" --msgbox "$text" 10 70 ;; + whiptail) whiptail --title "$APP_NAME" --msgbox "$text" 10 70 ;; + plain) printf "\n== %s ==\n%s\n\n" "$APP_NAME" "$text"; read -rp "Press Enter..." ;; + esac +} +inputbox() { + local prompt="$1" default="${2:-}" + case "$UI" in + dialog) dialog --title "$APP_NAME" --inputbox "$prompt" 10 70 "$default" 3>&1 1>&2 2>&3 ;; + whiptail) whiptail --title "$APP_NAME" --inputbox "$prompt" 10 70 "$default" 3>&1 1>&2 2>&3 ;; + plain) read -rp "$prompt [$default]: " ans; echo "${ans:-$default}" ;; + esac +} +menu() { + # args: prompt key1 "desc1" key2 "desc2" ... + local prompt="$1"; shift + case "$UI" in + dialog) dialog --title "$APP_NAME" --menu "$prompt" 20 70 12 "$@" 3>&1 1>&2 2>&3 ;; + whiptail) whiptail --title "$APP_NAME" --menu "$prompt" 20 70 12 "$@" 3>&1 1>&2 2>&3 ;; + plain) + echo; echo "$prompt" + local i=1 opts=() keys=() + while [ "$#" -gt 0 ]; do keys+=("$1"); shift; opts+=("$i) $1"); shift; i=$((i+1)); done + for o in "${opts[@]}"; do echo " $o"; done + read -rp "Choose 1-$((i-1)): " ix + echo "${keys[$((ix-1))]}" + ;; + esac +} +checklist() { + # returns space-separated chosen keys + local prompt="$1"; shift + case "$UI" in + dialog) + dialog --title "$APP_NAME" --checklist "$prompt" 20 70 12 "$@" 3>&1 1>&2 2>&3 | tr -d '"' + ;; + whiptail) + whiptail --title "$APP_NAME" --checklist "$prompt" 20 70 12 "$@" 3>&1 1>&2 2>&3 | tr -d '"' + ;; + plain) + echo; echo "$prompt" + local idx=1 keys=() descs=() + while [ "$#" -gt 0 ]; do + keys+=("$1"); shift + [ "$#" -gt 0 ] && descs+=("$1") && shift || break + [ "$#" -gt 0 ] && shift || break # skip ON/OFF + done + for k in "${!keys[@]}"; do echo " [ ] $((k+1)) ${keys[$k]} ${descs[$k]}"; done + read -rp "Enter numbers to select (e.g. 1 3): " picks + local out="" + for n in $picks; do out+="${keys[$((n-1))]} "; done + echo "$out" + ;; + esac +} + +# === Discover connectors === +discover_connectors() { + # Try gnome-monitor-config output first, then fallbacks + local out="" + if have gnome-monitor-config; then + # tolerant scrape for common connector names + out="$(gnome-monitor-config list 2>/dev/null | grep -Eo '\b(HDMI|DP|eDP|VGA|DVI|USB-C)(-[0-9]+)\b' | sort -u || true)" + fi + if [ -z "$out" ] && have xrandr; then + out="$(xrandr --listmonitors 2>/dev/null | grep -Eo '\b(HDMI|DP|eDP|VGA|DVI|USB-C)(-[0-9]+)\b' | sort -u || true)" + fi + if [ -z "$out" ]; then + msgbox "Couldn't auto-detect connectors. You'll be asked to type names like: DP-3, DP-10, eDP-1" + local manual + manual="$(inputbox "Enter connector names (space-separated):" "DP-3 DP-10 eDP-1")" + out="$manual" + fi + echo "$out" +} + +# === Build gnome-monitor-config command from choices === +build_command() { + local mirror_group="$1"; shift + local placement="$1"; shift # below/above/left/right + local offset_default_y="$1"; shift + local offset_default_x="$1"; shift + local others=("$@") + + local cmd=(gnome-monitor-config set) + + # First logical monitor: mirrored group as PRIMARY at 0,0 + local L1=(-Lp) + for m in $mirror_group; do L1+=(-M "$m"); done + L1+=(-x 0 -y 0) + cmd+=("${L1[@]}") + + # Place others relative to group + for o in "${others[@]}"; do + [ -z "$o" ] && continue + local x=0 y=0 + case "$placement" in + below) y="$offset_default_y"; x="$offset_default_x" ;; + above) y="-$offset_default_y"; x="$offset_default_x" ;; + right) x="$offset_default_x"; y="$offset_default_y" ;; + left) x="-$offset_default_x"; y="$offset_default_y" ;; + *) y="$offset_default_y"; x="$offset_default_x" ;; + esac + cmd+=(-L -M "$o" -x "$x" -y "$y") + done + + printf "%q " "${cmd[@]}" +} + +# === Snapshots === +snapshot_save() { + local label="$1" cmdline="$2" + local file="$SNAP_DIR/$(echo "$label" | tr '[:space:]' '_' | tr -cd '[:alnum:]_-.').sh" + cat >"$file" </dev/null 2>&1; then + msgbox "No snapshots saved yet." + return 1 + fi + + local menu_args=() + for f in "$SNAP_DIR"/*.sh; do + local base; base="$(basename "$f")" + menu_args+=("$base" "apply/delete") + done + local pick + pick="$(menu "Snapshots:" "${menu_args[@]}")" || return 1 + echo "$SNAP_DIR/$pick" +} + +# === Main flow === +main_menu() { + while true; do + case "$(menu "Choose an action:" \ + new "Create & apply a new layout" \ + apply "Apply a saved snapshot" \ + delete "Delete a saved snapshot" \ + quit "Quit")" in + new) + new_layout + ;; + apply) + local f; f="$(snapshot_pick)" || continue + if [ -n "${f:-}" ]; then + bash "$f" && msgbox "Applied snapshot: $(basename "$f")" + fi + ;; + delete) + local f; f="$(snapshot_pick)" || continue + [ -n "${f:-}" ] && rm -f "$f" && msgbox "Deleted snapshot: $(basename "$f")" + ;; + quit|"") + exit 0 + ;; + esac + done +} + +new_layout() { + local conns; conns="$(discover_connectors)" + # build checklist args: key desc ON/OFF with additional info + local cl_args=() + for c in $conns; do + local info; info="$(describe_connector "$c")" + cl_args+=("$c" "$info" OFF) + done + local picked; picked="$(checklist "Pick one or more connectors to MIRROR as a single logical monitor (these will be your 'big' display):" "${cl_args[@]}")" || return + if [ -z "$picked" ]; then msgbox "You must select at least one connector."; return; fi + + # Show a quick summary of current connectors before placement + local summary="" + for c in $conns; do summary+="$c $(describe_connector "$c")\n"; done + msgbox "Detected connectors:\n$summary" + + # Pick placement for the remaining connectors + local place; place="$(menu "Where do you want to place the OTHER monitors relative to the mirrored group?" \ + below "Below (typical laptop-under-desktop)" \ + above "Above" \ + left "Left" \ + right "Right")" || return + + # Default offsets (you can tweak them) + local offy offx + offy="$(inputbox "Pixel offset for Y (distance from mirrored group). Example: 2160" "2160")" + offx="$(inputbox "Pixel offset for X. Example: 0" "0")" + + # Others = conns not in picked + read -ra all_arr <<<"$conns" + read -ra pick_arr <<<"$picked" + # build associative map for fast exclude + declare -A is_pick; for p in "${pick_arr[@]}"; do is_pick["$p"]=1; done + local others=() + for a in "${all_arr[@]}"; do + if [ -z "${is_pick[$a]+x}" ]; then others+=("$a"); fi + done + + # Confirm which of the remaining to include (maybe none), showing info + if [ "${#others[@]}" -gt 0 ]; then + local cl2_args=() + for o in "${others[@]}"; do + local info; info="$(describe_connector "$o")" + cl2_args+=("$o" "$info" ON) + done + local picked2; picked2="$(checklist "Select which of the remaining connectors to include (they will be placed $place the group):" "${cl2_args[@]}")" || picked2="" + read -ra others <<<"$picked2" + fi + + local cmdline; cmdline="$(build_command "$picked" "$place" "$offy" "$offx" "${others[@]:-}")" + # Try to apply + if $cmdline; then + msgbox "Applied:\n$cmdline" + # Offer to snapshot + case "$(menu "Snapshot this working layout?" yes "Save snapshot" no "Skip")" in + yes) + local label; label="$(inputbox "Snapshot label:" "mirrored_group_$(date +%Y%m%d_%H%M%S)")" + local file; file="$(snapshot_save "$label" "$cmdline")" + msgbox "Saved snapshot: $file" + ;; + *) : ;; + esac + else + msgbox "Failed to apply the layout.\nCommand was:\n$cmdline" + fi +} + +# === Checks === +if ! have gnome-monitor-config; then + msgbox "gnome-monitor-config is required (Wayland). Please install it (part of GNOME).\nAborting." + exit 1 +fi + +main_menu