Share ~/.config/hypr/monitors.json with the BOS Display panel
Some checks failed
dev release / build (push) Failing after 0s
Some checks failed
dev release / build (push) Failing after 0s
Read the file as the initial layout on start. After a successful hyprctl apply (including applying a named profile), write pretty JSON so Hyprland and bos-settings stay in sync. Profiles remain named snapshots under ~/.config/breadmon/profiles/. Pin bread-utils to bread-ecosystem v0.7.2.
This commit is contained in:
parent
8d43a03d44
commit
d98b5b4fad
15 changed files with 749 additions and 168 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -33,8 +33,8 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-utils"
|
name = "bread-utils"
|
||||||
version = "0.3.1"
|
version = "0.7.2"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ toml = "0.8"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] }
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,10 @@ connection (`BreadClient::emit` never blocks or errors the caller).
|
||||||
|
|
||||||
This event is about breadmon's own live apply (`hyprctl eval
|
This event is about breadmon's own live apply (`hyprctl eval
|
||||||
'hl.monitor({...})'` on [BOS](https://git.breadway.dev/breadway/bos)-patched
|
'hl.monitor({...})'` on [BOS](https://git.breadway.dev/breadway/bos)-patched
|
||||||
Hyprland). It is **not** fired by the `bos-settings` Display panel, which
|
Hyprland). After that apply succeeds, breadmon also writes
|
||||||
writes `~/.config/hypr/monitors.json` and is a separate store breadmon
|
`~/.config/hypr/monitors.json` (the store shared with the `bos-settings`
|
||||||
never reads or writes. Vanilla/upstream Hyprland has no `eval` request
|
Display panel). The event is **not** fired by Display itself — that GUI
|
||||||
|
only edits the JSON. Vanilla/upstream Hyprland has no `eval` request
|
||||||
and no `hl.monitor()`, so apply fails there and this event is not
|
and no `hl.monitor()`, so apply fails there and this event is not
|
||||||
published.
|
published.
|
||||||
|
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply a live layout on [BOS](https://git.breadway.dev/breadway/bos)-patched Hyprland.
|
A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply a live layout on [BOS](https://git.breadway.dev/breadway/bos)-patched Hyprland.
|
||||||
|
|
||||||
breadmon is **not** the Display panel in `bos-settings`. That panel edits `~/.config/hypr/monitors.json` (Hyprland's login/reload layout store). breadmon keeps its own named snapshots as TOML under `~/.config/breadmon/profiles/` and applies them live through BOS Hyprland. The two stores are independent — changing one does not update the other.
|
The Display panel in `bos-settings` (GUI) and breadmon (TUI) share `~/.config/hypr/monitors.json` — the layout Hyprland reads at login/reload. Applying in breadmon writes that file so the settings app and the next session stay in sync. Named profiles remain optional snapshots under `~/.config/breadmon/profiles/`.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ Finds the best common mode between two monitors and sets one to mirror the other
|
||||||
|
|
||||||
### Profiles
|
### Profiles
|
||||||
|
|
||||||
Named snapshots of the current monitor configuration, stored as TOML files.
|
Named snapshots of the current monitor configuration, stored as TOML files. Loading a profile updates the TUI; applying it (`a`) also writes `monitors.json`.
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
|
|
@ -95,8 +95,8 @@ Profiles are saved to `~/.config/breadmon/profiles/`.
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) |
|
| `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) and write `~/.config/hypr/monitors.json` |
|
||||||
| `s` | Save current configuration as a profile |
|
| `s` | Write `~/.config/hypr/monitors.json` without a live apply |
|
||||||
| `r` | Refresh monitor list from Hyprland |
|
| `r` | Refresh monitor list from Hyprland |
|
||||||
| `Ctrl+Z` | Undo last change (up to 20 steps) |
|
| `Ctrl+Z` | Undo last change (up to 20 steps) |
|
||||||
| `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) |
|
| `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) |
|
||||||
|
|
@ -105,9 +105,19 @@ breadmon also listens on Hyprland's event socket and reloads the monitor list au
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Profiles are plain TOML files under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab; there is no hand-written config file.
|
**Shared store:** `~/.config/hypr/monitors.json` — the same file the bos-settings Display panel edits and Hyprland applies on login/reload. breadmon is the TUI; Display is the GUI. Schema:
|
||||||
|
|
||||||
This is not `~/.config/hypr/monitors.json`. That file is the persistent Hyprland layout edited by the `bos-settings` Display panel and applied on login/reload. breadmon never reads or writes it.
|
```json
|
||||||
|
{
|
||||||
|
"monitors": [
|
||||||
|
{ "output": "", "mode": "preferred", "position": "auto", "scale": "auto", "mirror": "<optional string>" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty `output` is the wildcard default (any connector). breadmon loads this file on start (overlaid onto the live `hyprctl` list) and writes it — pretty JSON — after a successful apply, and when you press `s`.
|
||||||
|
|
||||||
|
**Named snapshots:** plain TOML under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab. Applying a profile writes `monitors.json` so Hyprland and Display stay in sync.
|
||||||
|
|
||||||
## bread event integration
|
## bread event integration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,11 @@ mod tests {
|
||||||
Monitor {
|
Monitor {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
active_mode: Mode { width: w, height: h, refresh: 60.0 },
|
active_mode: Mode {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
refresh: 60.0,
|
||||||
|
},
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
|
|
||||||
57
src/main.rs
57
src/main.rs
|
|
@ -3,6 +3,7 @@ mod layout;
|
||||||
mod mirror;
|
mod mirror;
|
||||||
mod monitor;
|
mod monitor;
|
||||||
mod profile;
|
mod profile;
|
||||||
|
mod store;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
@ -10,8 +11,7 @@ use std::io;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{
|
event::{
|
||||||
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind,
|
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, MouseEventKind,
|
||||||
MouseEventKind,
|
|
||||||
},
|
},
|
||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
|
@ -36,10 +36,15 @@ enum AppEvent {
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let monitors = monitor::load_monitors().await.unwrap_or_else(|e| {
|
let mut monitors = monitor::load_monitors().await.unwrap_or_else(|e| {
|
||||||
eprintln!("Warning: could not load monitors: {}", e);
|
eprintln!("Warning: could not load monitors: {}", e);
|
||||||
vec![]
|
vec![]
|
||||||
});
|
});
|
||||||
|
match store::load() {
|
||||||
|
Ok(Some(file)) => store::apply_to_monitors(&file, &mut monitors),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => eprintln!("Warning: could not load monitors.json: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
// Terminal setup
|
// Terminal setup
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
|
|
@ -52,7 +57,11 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
// Restore terminal
|
// Restore terminal
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
result
|
result
|
||||||
|
|
@ -162,23 +171,18 @@ async fn run(
|
||||||
crossterm::event::KeyCode::Char('s') => {
|
crossterm::event::KeyCode::Char('s') => {
|
||||||
ui::layout_view::trigger_save(&mut state);
|
ui::layout_view::trigger_save(&mut state);
|
||||||
}
|
}
|
||||||
crossterm::event::KeyCode::Char('r') => {
|
crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await {
|
||||||
match monitor::load_monitors().await {
|
Ok(monitors) => {
|
||||||
Ok(monitors) => {
|
state.monitors = monitors;
|
||||||
state.monitors = monitors;
|
state.layout.clamp_selected(state.monitors.len());
|
||||||
state.layout.clamp_selected(state.monitors.len());
|
state.dirty = false;
|
||||||
state.dirty = false;
|
state.active_profile = None;
|
||||||
state.active_profile = None;
|
state.set_status("Monitors refreshed.", StatusLevel::Success);
|
||||||
state.set_status("Monitors refreshed.", StatusLevel::Success);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
state.set_status(
|
|
||||||
format!("Refresh failed: {}", e),
|
|
||||||
StatusLevel::Error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
|
state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error);
|
||||||
|
}
|
||||||
|
},
|
||||||
_ => {
|
_ => {
|
||||||
if !ui::handle_key(key, &mut state) {
|
if !ui::handle_key(key, &mut state) {
|
||||||
break;
|
break;
|
||||||
|
|
@ -193,7 +197,18 @@ async fn run(
|
||||||
match monitor::apply_monitors(&state.monitors).await {
|
match monitor::apply_monitors(&state.monitors).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
bread_events::emit_applied(state.active_profile.as_deref());
|
bread_events::emit_applied(state.active_profile.as_deref());
|
||||||
state.set_status("Applied.", StatusLevel::Success);
|
match store::save_from_monitors(&state.monitors) {
|
||||||
|
Ok(()) => {
|
||||||
|
state.dirty = false;
|
||||||
|
state.set_status("Applied.", StatusLevel::Success);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.set_status(
|
||||||
|
format!("Applied, but monitors.json write failed: {}", e),
|
||||||
|
StatusLevel::Error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
state.set_status(format!("Apply failed: {}", e), StatusLevel::Error);
|
state.set_status(format!("Apply failed: {}", e), StatusLevel::Error);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,11 @@ pub struct MirrorResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gcd(a: u32, b: u32) -> u32 {
|
fn gcd(a: u32, b: u32) -> u32 {
|
||||||
if b == 0 { a } else { gcd(b, a % b) }
|
if b == 0 {
|
||||||
|
a
|
||||||
|
} else {
|
||||||
|
gcd(b, a % b)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reduced_ar(w: u32, h: u32) -> (u32, u32) {
|
fn reduced_ar(w: u32, h: u32) -> (u32, u32) {
|
||||||
|
|
@ -35,7 +39,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
|
||||||
// Group source modes by reduced AR
|
// Group source modes by reduced AR
|
||||||
let mut src_by_ar: HashMap<(u32, u32), Vec<&Mode>> = HashMap::new();
|
let mut src_by_ar: HashMap<(u32, u32), Vec<&Mode>> = HashMap::new();
|
||||||
for m in src_modes {
|
for m in src_modes {
|
||||||
src_by_ar.entry(reduced_ar(m.width, m.height)).or_default().push(m);
|
src_by_ar
|
||||||
|
.entry(reduced_ar(m.width, m.height))
|
||||||
|
.or_default()
|
||||||
|
.push(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -53,8 +60,15 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
|
||||||
|
|
||||||
if src_by_ar.contains_key(&tgt_ar) {
|
if src_by_ar.contains_key(&tgt_ar) {
|
||||||
// Check if we already have this exact pair
|
// 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) {
|
if !candidates
|
||||||
candidates.push(Candidate { src_ar: tgt_ar, tgt_ar, is_exact: true });
|
.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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -63,9 +77,16 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
|
||||||
for &s_ar in src_by_ar.keys() {
|
for &s_ar in src_by_ar.keys() {
|
||||||
let s_ratio = ratio_f64(s_ar);
|
let s_ratio = ratio_f64(s_ar);
|
||||||
if (s_ratio - tgt_ratio).abs() / s_ratio < 0.05
|
if (s_ratio - tgt_ratio).abs() / s_ratio < 0.05
|
||||||
&& !candidates.iter().any(|c| c.src_ar == s_ar && c.tgt_ar == tgt_ar) {
|
&& !candidates
|
||||||
candidates.push(Candidate { src_ar: s_ar, tgt_ar, is_exact: false });
|
.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,7 +168,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let chosen_refresh = if !exact_common.is_empty() {
|
let chosen_refresh = if !exact_common.is_empty() {
|
||||||
exact_common.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
exact_common
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.fold(f64::NEG_INFINITY, f64::max)
|
||||||
} else {
|
} else {
|
||||||
// Near-match within 1 Hz
|
// Near-match within 1 Hz
|
||||||
let near: Vec<f64> = src_refreshes
|
let near: Vec<f64> = src_refreshes
|
||||||
|
|
@ -164,7 +188,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
|
||||||
near.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
near.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: max source refresh
|
// Fallback: max source refresh
|
||||||
src_refreshes.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
src_refreshes
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.fold(f64::NEG_INFINITY, f64::max)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -217,7 +244,7 @@ pub fn refresh_match_label(result: &MirrorResult) -> &'static str {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::monitor::{Transform};
|
use crate::monitor::Transform;
|
||||||
|
|
||||||
fn make_monitor_with_modes(name: &str, modes: Vec<Mode>) -> Monitor {
|
fn make_monitor_with_modes(name: &str, modes: Vec<Mode>) -> Monitor {
|
||||||
let active = modes[0].clone();
|
let active = modes[0].clone();
|
||||||
|
|
@ -240,7 +267,11 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn m(w: u32, h: u32, r: f64) -> Mode {
|
fn m(w: u32, h: u32, r: f64) -> Mode {
|
||||||
Mode { width: w, height: h, refresh: r }
|
Mode {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
refresh: r,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -145,11 +145,15 @@ impl Monitor {
|
||||||
.collect();
|
.collect();
|
||||||
// Sort descending by pixels then refresh for consistent ordering
|
// Sort descending by pixels then refresh for consistent ordering
|
||||||
modes.sort_by(|a, b| {
|
modes.sort_by(|a, b| {
|
||||||
b.pixels()
|
b.pixels().cmp(&a.pixels()).then(
|
||||||
.cmp(&a.pixels())
|
b.refresh
|
||||||
.then(b.refresh.partial_cmp(&a.refresh).unwrap_or(std::cmp::Ordering::Equal))
|
.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
|
||||||
});
|
});
|
||||||
modes.dedup_by(|a, b| a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01);
|
|
||||||
|
|
||||||
let active_mode = Mode {
|
let active_mode = Mode {
|
||||||
width: raw.width,
|
width: raw.width,
|
||||||
|
|
@ -214,8 +218,10 @@ impl Monitor {
|
||||||
if self.physical_width_mm == 0 || self.physical_height_mm == 0 {
|
if self.physical_width_mm == 0 || self.physical_height_mm == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let diag_px = ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt();
|
let diag_px =
|
||||||
let diag_mm = ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt();
|
((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))
|
Some(diag_px / (diag_mm / 25.4))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,8 +274,10 @@ pub async fn load_monitors() -> Result<Vec<Monitor>> {
|
||||||
|
|
||||||
// Hyprland reports mirrorOf as a numeric ID string when using `monitors all`.
|
// 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>`.
|
// Resolve to monitor name so format_hypr_line emits the correct `mirror,<name>`.
|
||||||
let id_to_name: std::collections::HashMap<String, String> =
|
let id_to_name: std::collections::HashMap<String, String> = raw
|
||||||
raw.iter().map(|r| (r.id.to_string(), r.name.clone())).collect();
|
.iter()
|
||||||
|
.map(|r| (r.id.to_string(), r.name.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(raw
|
Ok(raw
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -284,6 +292,7 @@ pub async fn load_monitors() -> Result<Vec<Monitor>> {
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub fn format_hypr_line(m: &Monitor) -> String {
|
pub fn format_hypr_line(m: &Monitor) -> String {
|
||||||
if let Some(src) = &m.mirror_of {
|
if let Some(src) = &m.mirror_of {
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -444,7 +453,11 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_compact_roundtrip() {
|
fn mode_compact_roundtrip() {
|
||||||
let m = Mode { width: 1920, height: 1080, refresh: 60.0 };
|
let m = Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh: 60.0,
|
||||||
|
};
|
||||||
let s = m.compact();
|
let s = m.compact();
|
||||||
let m2 = Mode::parse(&format!("{}Hz", s)).unwrap();
|
let m2 = Mode::parse(&format!("{}Hz", s)).unwrap();
|
||||||
assert_eq!(m.width, m2.width);
|
assert_eq!(m.width, m2.width);
|
||||||
|
|
@ -456,7 +469,11 @@ mod tests {
|
||||||
let m = Monitor {
|
let m = Monitor {
|
||||||
name: "eDP-1".into(),
|
name: "eDP-1".into(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
active_mode: Mode { width: 1920, height: 1200, refresh: 60.0 },
|
active_mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1200,
|
||||||
|
refresh: 60.0,
|
||||||
|
},
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
|
@ -480,7 +497,11 @@ mod tests {
|
||||||
let m = Monitor {
|
let m = Monitor {
|
||||||
name: "HDMI-A-1".into(),
|
name: "HDMI-A-1".into(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
active_mode: Mode { width: 1920, height: 1080, refresh: 60.0 },
|
active_mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh: 60.0,
|
||||||
|
},
|
||||||
x: 1920,
|
x: 1920,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,7 @@ pub fn list() -> Result<Vec<String>> {
|
||||||
|
|
||||||
pub fn delete(name: &str) -> Result<()> {
|
pub fn delete(name: &str) -> Result<()> {
|
||||||
let path = profiles_dir().join(format!("{}.toml", name));
|
let path = profiles_dir().join(format!("{}.toml", name));
|
||||||
std::fs::remove_file(&path)
|
std::fs::remove_file(&path).with_context(|| format!("failed to delete profile '{}'", name))
|
||||||
.with_context(|| format!("failed to delete profile '{}'", name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile {
|
pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile {
|
||||||
|
|
@ -150,7 +149,11 @@ mod tests {
|
||||||
Monitor {
|
Monitor {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
active_mode: Mode { width: w, height: h, refresh: 60.0 },
|
active_mode: Mode {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
refresh: 60.0,
|
||||||
|
},
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
|
|
||||||
369
src/store.rs
Normal file
369
src/store.rs
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
//! Shared Hyprland layout store: `~/.config/hypr/monitors.json`.
|
||||||
|
//!
|
||||||
|
//! Same schema as bos-settings `MonitorRule` and
|
||||||
|
//! `iso/airootfs/etc/skel/.config/hypr/scripts/display/monitors.lua`.
|
||||||
|
//! Empty `output` is the wildcard default (matches any connector).
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::monitor::{Mode, Monitor};
|
||||||
|
|
||||||
|
fn default_mode() -> String {
|
||||||
|
"preferred".to_string()
|
||||||
|
}
|
||||||
|
fn default_position() -> String {
|
||||||
|
"auto".to_string()
|
||||||
|
}
|
||||||
|
fn default_scale() -> String {
|
||||||
|
"auto".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `hl.monitor()` rule. Field names and defaults must stay in sync with
|
||||||
|
/// bos-settings `MonitorRule` and the ISO `monitors.lua` loader.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct MonitorRule {
|
||||||
|
pub output: String,
|
||||||
|
#[serde(default = "default_mode")]
|
||||||
|
pub mode: String,
|
||||||
|
#[serde(default = "default_position")]
|
||||||
|
pub position: String,
|
||||||
|
#[serde(default = "default_scale")]
|
||||||
|
pub scale: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mirror: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MonitorRule {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
output: String::new(),
|
||||||
|
mode: default_mode(),
|
||||||
|
position: default_position(),
|
||||||
|
scale: default_scale(),
|
||||||
|
mirror: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct MonitorsFile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub monitors: Vec<MonitorRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MonitorsFile {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
monitors: vec![MonitorRule::default()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/.config/hypr/monitors.json` — same path Hyprland and bos-settings use.
|
||||||
|
pub fn config_path() -> PathBuf {
|
||||||
|
bread_utils::xdg::config_dir("hypr").join("monitors.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Option<MonitorsFile>> {
|
||||||
|
load_from(&config_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from(path: &Path) -> Result<Option<MonitorsFile>> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||||
|
let file: MonitorsFile = serde_json::from_str(&content)
|
||||||
|
.with_context(|| format!("failed to parse {}", path.display()))?;
|
||||||
|
// Empty file ≡ missing: Lua falls back to the wildcard default rather
|
||||||
|
// than applying zero rules (which can black-screen the session).
|
||||||
|
if file.monitors.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(file: &MonitorsFile) -> Result<()> {
|
||||||
|
save_to(&config_path(), file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_to(path: &Path, file: &MonitorsFile) -> Result<()> {
|
||||||
|
let json = serde_json::to_string_pretty(file).context("failed to serialize monitors.json")?;
|
||||||
|
bread_utils::atomic::write_atomic_backed_up(path, &json)
|
||||||
|
.with_context(|| format!("failed to write {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_from_monitors(monitors: &[Monitor]) -> Result<()> {
|
||||||
|
save(&from_monitors(monitors))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the TUI layout as named `hl.monitor()` rules. Mirror slaves are
|
||||||
|
/// omitted (mirror is recorded on the source, matching `hl.monitor()`). If
|
||||||
|
/// nothing is writable, emit the wildcard default so the file is never empty.
|
||||||
|
pub fn from_monitors(monitors: &[Monitor]) -> MonitorsFile {
|
||||||
|
let mut source_to_slave: HashMap<&str, &str> = HashMap::new();
|
||||||
|
for m in monitors {
|
||||||
|
if let Some(src) = &m.mirror_of {
|
||||||
|
source_to_slave.insert(src.as_str(), m.name.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rules = Vec::new();
|
||||||
|
for m in monitors {
|
||||||
|
if m.disabled || m.mirror_of.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let refresh = (m.active_mode.refresh + 0.5) as u32;
|
||||||
|
rules.push(MonitorRule {
|
||||||
|
output: m.name.clone(),
|
||||||
|
mode: format!(
|
||||||
|
"{}x{}@{}",
|
||||||
|
m.active_mode.width, m.active_mode.height, refresh
|
||||||
|
),
|
||||||
|
position: format!("{}x{}", m.x, m.y),
|
||||||
|
scale: format!("{:.2}", m.scale),
|
||||||
|
mirror: source_to_slave
|
||||||
|
.get(m.name.as_str())
|
||||||
|
.map(|s| (*s).to_owned()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if rules.is_empty() {
|
||||||
|
MonitorsFile::default()
|
||||||
|
} else {
|
||||||
|
MonitorsFile { monitors: rules }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overlay persisted rules onto live `hyprctl` monitors (matched by name;
|
||||||
|
/// empty `output` is the wildcard fallback). `preferred` / `auto` leave the
|
||||||
|
/// live value. A file with at least one named output is treated as a full
|
||||||
|
/// layout and replaces live mirrors; a wildcard-only file does not.
|
||||||
|
pub fn apply_to_monitors(file: &MonitorsFile, monitors: &mut [Monitor]) {
|
||||||
|
let has_specific = file.monitors.iter().any(|r| !r.output.is_empty());
|
||||||
|
if has_specific {
|
||||||
|
for m in monitors.iter_mut() {
|
||||||
|
m.mirror_of = None;
|
||||||
|
}
|
||||||
|
for rule in &file.monitors {
|
||||||
|
let Some(slave_name) = rule.mirror.as_deref().filter(|s| !s.is_empty()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if rule.output.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(slave) = monitors.iter_mut().find(|m| m.name == slave_name) {
|
||||||
|
slave.mirror_of = Some(rule.output.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for m in monitors.iter_mut() {
|
||||||
|
if let Some(rule) = find_rule(&file.monitors, &m.name) {
|
||||||
|
apply_rule_fields(m, rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_rule<'a>(rules: &'a [MonitorRule], name: &str) -> Option<&'a MonitorRule> {
|
||||||
|
rules
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.output == name)
|
||||||
|
.or_else(|| rules.iter().find(|r| r.output.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_rule_fields(m: &mut Monitor, rule: &MonitorRule) {
|
||||||
|
if rule.mode != "preferred" {
|
||||||
|
if let Some(mode) =
|
||||||
|
Mode::parse(&format!("{}Hz", rule.mode)).or_else(|| Mode::parse(&rule.mode))
|
||||||
|
{
|
||||||
|
m.active_mode = mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rule.position != "auto" {
|
||||||
|
if let Some((x, y)) = parse_position(&rule.position) {
|
||||||
|
m.x = x;
|
||||||
|
m.y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rule.scale != "auto" {
|
||||||
|
if let Ok(scale) = rule.scale.parse::<f64>() {
|
||||||
|
if scale > 0.0 {
|
||||||
|
m.scale = scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_position(s: &str) -> Option<(i32, i32)> {
|
||||||
|
let (x, y) = s.split_once('x')?;
|
||||||
|
Some((x.parse().ok()?, y.parse().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::monitor::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 iso_default_parses() {
|
||||||
|
let json = r#"{
|
||||||
|
"monitors": [
|
||||||
|
{ "output": "", "mode": "preferred", "position": "auto", "scale": "auto" }
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let file: MonitorsFile = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(file.monitors.len(), 1);
|
||||||
|
assert_eq!(file.monitors[0], MonitorRule::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pretty_roundtrip_omits_absent_mirror() {
|
||||||
|
let file = MonitorsFile::default();
|
||||||
|
let json = serde_json::to_string_pretty(&file).unwrap();
|
||||||
|
assert!(json.contains("\"output\": \"\""));
|
||||||
|
assert!(json.contains("\"mode\": \"preferred\""));
|
||||||
|
assert!(!json.contains("mirror"));
|
||||||
|
let back: MonitorsFile = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(file, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_monitors_writes_named_rules_and_source_mirror() {
|
||||||
|
let mut hdmi = make_monitor("HDMI-A-1", 1920, 1080, 1920, 0);
|
||||||
|
hdmi.mirror_of = Some("eDP-1".into());
|
||||||
|
let file = from_monitors(&[make_monitor("eDP-1", 1920, 1200, 0, 0), hdmi]);
|
||||||
|
assert_eq!(file.monitors.len(), 1);
|
||||||
|
let rule = &file.monitors[0];
|
||||||
|
assert_eq!(rule.output, "eDP-1");
|
||||||
|
assert_eq!(rule.mode, "1920x1200@60");
|
||||||
|
assert_eq!(rule.position, "0x0");
|
||||||
|
assert_eq!(rule.scale, "1.00");
|
||||||
|
assert_eq!(rule.mirror.as_deref(), Some("HDMI-A-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_monitors_empty_or_all_slaves_emits_wildcard() {
|
||||||
|
let mut only_slave = make_monitor("HDMI-A-1", 1920, 1080, 0, 0);
|
||||||
|
only_slave.mirror_of = Some("missing".into());
|
||||||
|
assert_eq!(from_monitors(&[]), MonitorsFile::default());
|
||||||
|
assert_eq!(from_monitors(&[only_slave]), MonitorsFile::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wildcard_overlay_leaves_live_geometry_and_mirrors() {
|
||||||
|
let file = MonitorsFile::default();
|
||||||
|
let mut monitors = vec![make_monitor("eDP-1", 1920, 1200, 10, 20)];
|
||||||
|
monitors[0].scale = 1.5;
|
||||||
|
monitors[0].mirror_of = Some("HDMI-A-1".into());
|
||||||
|
apply_to_monitors(&file, &mut monitors);
|
||||||
|
assert_eq!(monitors[0].x, 10);
|
||||||
|
assert_eq!(monitors[0].y, 20);
|
||||||
|
assert!((monitors[0].scale - 1.5).abs() < f64::EPSILON);
|
||||||
|
assert_eq!(monitors[0].mirror_of.as_deref(), Some("HDMI-A-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn specific_overlay_applies_fields_and_replaces_mirrors() {
|
||||||
|
let file = MonitorsFile {
|
||||||
|
monitors: vec![
|
||||||
|
MonitorRule {
|
||||||
|
output: "eDP-1".into(),
|
||||||
|
mode: "1920x1200@60".into(),
|
||||||
|
position: "0x0".into(),
|
||||||
|
scale: "1.25".into(),
|
||||||
|
mirror: Some("HDMI-A-1".into()),
|
||||||
|
},
|
||||||
|
MonitorRule {
|
||||||
|
output: "DP-1".into(),
|
||||||
|
mode: "2560x1440@144".into(),
|
||||||
|
position: "-2560x0".into(),
|
||||||
|
scale: "1".into(),
|
||||||
|
mirror: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let mut monitors = vec![
|
||||||
|
make_monitor("eDP-1", 1600, 900, 100, 100),
|
||||||
|
make_monitor("HDMI-A-1", 1920, 1080, 200, 0),
|
||||||
|
make_monitor("DP-1", 1920, 1080, 300, 0),
|
||||||
|
];
|
||||||
|
monitors[1].mirror_of = Some("DP-1".into());
|
||||||
|
apply_to_monitors(&file, &mut monitors);
|
||||||
|
|
||||||
|
assert_eq!(monitors[0].active_mode.width, 1920);
|
||||||
|
assert_eq!(monitors[0].active_mode.height, 1200);
|
||||||
|
assert!((monitors[0].active_mode.refresh - 60.0).abs() < 0.01);
|
||||||
|
assert_eq!(monitors[0].x, 0);
|
||||||
|
assert_eq!(monitors[0].y, 0);
|
||||||
|
assert!((monitors[0].scale - 1.25).abs() < f64::EPSILON);
|
||||||
|
|
||||||
|
assert_eq!(monitors[1].mirror_of.as_deref(), Some("eDP-1"));
|
||||||
|
|
||||||
|
assert_eq!(monitors[2].active_mode.width, 2560);
|
||||||
|
assert_eq!(monitors[2].x, -2560);
|
||||||
|
assert!(monitors[2].mirror_of.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_missing_or_empty_is_none() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"breadmon-store-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
"empty"
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let missing = dir.join("nope.json");
|
||||||
|
assert!(load_from(&missing).unwrap().is_none());
|
||||||
|
|
||||||
|
let empty = dir.join("empty.json");
|
||||||
|
std::fs::write(&empty, "{ \"monitors\": [] }\n").unwrap();
|
||||||
|
assert!(load_from(&empty).unwrap().is_none());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_to_roundtrips() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"breadmon-store-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
"save"
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("monitors.json");
|
||||||
|
let file = from_monitors(&[make_monitor("eDP-1", 1920, 1200, 0, 0)]);
|
||||||
|
save_to(&path, &file).unwrap();
|
||||||
|
let loaded = load_from(&path).unwrap().unwrap();
|
||||||
|
assert_eq!(loaded, file);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -125,7 +125,10 @@ impl ConfigState {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prev_field(&mut self) {
|
fn prev_field(&mut self) {
|
||||||
self.focused = self.focused.checked_sub(1).unwrap_or(ConfigField::ALL.len() - 1);
|
self.focused = self
|
||||||
|
.focused
|
||||||
|
.checked_sub(1)
|
||||||
|
.unwrap_or(ConfigField::ALL.len() - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,14 +317,22 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
ConfigField::Vrr => match event.code {
|
ConfigField::Vrr => match event.code {
|
||||||
KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => {
|
KeyCode::Char('h')
|
||||||
|
| KeyCode::Left
|
||||||
|
| KeyCode::Char('l')
|
||||||
|
| KeyCode::Right
|
||||||
|
| KeyCode::Char(' ') => {
|
||||||
state.monitors[idx].vrr = !state.monitors[idx].vrr;
|
state.monitors[idx].vrr = !state.monitors[idx].vrr;
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
ConfigField::Dpms => match event.code {
|
ConfigField::Dpms => match event.code {
|
||||||
KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => {
|
KeyCode::Char('h')
|
||||||
|
| KeyCode::Left
|
||||||
|
| KeyCode::Char('l')
|
||||||
|
| KeyCode::Right
|
||||||
|
| KeyCode::Char(' ') => {
|
||||||
state.monitors[idx].dpms = !state.monitors[idx].dpms;
|
state.monitors[idx].dpms = !state.monitors[idx].dpms;
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
|
|
@ -358,7 +369,11 @@ fn sync_mode_to_monitor(state: &mut AppState, idx: usize) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) {
|
fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) {
|
||||||
let chosen = state.config.mirror_options.get(state.config.mirror_idx).cloned();
|
let chosen = state
|
||||||
|
.config
|
||||||
|
.mirror_options
|
||||||
|
.get(state.config.mirror_idx)
|
||||||
|
.cloned();
|
||||||
state.monitors[idx].mirror_of = match chosen.as_deref() {
|
state.monitors[idx].mirror_of = match chosen.as_deref() {
|
||||||
Some("(none)") | None => None,
|
Some("(none)") | None => None,
|
||||||
Some(s) => Some(s.to_owned()),
|
Some(s) => Some(s.to_owned()),
|
||||||
|
|
@ -366,7 +381,10 @@ fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn commit_scale(state: &mut AppState) {
|
fn commit_scale(state: &mut AppState) {
|
||||||
let idx = state.config.monitor_idx.min(state.monitors.len().saturating_sub(1));
|
let idx = state
|
||||||
|
.config
|
||||||
|
.monitor_idx
|
||||||
|
.min(state.monitors.len().saturating_sub(1));
|
||||||
if let Ok(v) = state.config.scale_str.parse::<f64>() {
|
if let Ok(v) = state.config.scale_str.parse::<f64>() {
|
||||||
state.monitors[idx].scale = v.clamp(0.1, 10.0);
|
state.monitors[idx].scale = v.clamp(0.1, 10.0);
|
||||||
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
|
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
|
||||||
|
|
@ -405,7 +423,11 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
};
|
};
|
||||||
let header = format!(" {} — {}{}", m.name, m.description, ppi_hint);
|
let header = format!(" {} — {}{}", m.name, m.description, ppi_hint);
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(header).style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
Paragraph::new(header).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
chunks[0],
|
chunks[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -463,20 +485,31 @@ fn field_value(field: ConfigField, state: &AppState, m: &Monitor) -> String {
|
||||||
}
|
}
|
||||||
ConfigField::Scale => {
|
ConfigField::Scale => {
|
||||||
if state.config.scale_editing {
|
if state.config.scale_editing {
|
||||||
format!("{}| (Enter to commit, ,/. for ±0.1)", state.config.scale_str)
|
format!(
|
||||||
|
"{}| (Enter to commit, ,/. for ±0.1)",
|
||||||
|
state.config.scale_str
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("{} (,/. for ±0.1)", state.config.scale_str)
|
format!("{} (,/. for ±0.1)", state.config.scale_str)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ConfigField::Transform => Transform::all()
|
ConfigField::Transform => Transform::all()
|
||||||
[state.config.transform_idx.min(Transform::all().len() - 1)]
|
[state.config.transform_idx.min(Transform::all().len() - 1)]
|
||||||
.label()
|
.label()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
ConfigField::Vrr => {
|
ConfigField::Vrr => {
|
||||||
if m.vrr { "ON".to_owned() } else { "OFF".to_owned() }
|
if m.vrr {
|
||||||
|
"ON".to_owned()
|
||||||
|
} else {
|
||||||
|
"OFF".to_owned()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ConfigField::Dpms => {
|
ConfigField::Dpms => {
|
||||||
if m.dpms { "ON".to_owned() } else { "OFF".to_owned() }
|
if m.dpms {
|
||||||
|
"ON".to_owned()
|
||||||
|
} else {
|
||||||
|
"OFF".to_owned()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ConfigField::MirrorOf => state
|
ConfigField::MirrorOf => state
|
||||||
.config
|
.config
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ use ratatui::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
layout::{auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position, world_to_canvas},
|
layout::{
|
||||||
|
auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position,
|
||||||
|
world_to_canvas,
|
||||||
|
},
|
||||||
monitor::Monitor,
|
monitor::Monitor,
|
||||||
ui::{AppState, DragState, StatusLevel, Tab},
|
ui::{AppState, DragState, StatusLevel, Tab},
|
||||||
};
|
};
|
||||||
|
|
@ -49,7 +52,9 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
state.config.sync_from_monitor(state.layout.selected, &state.monitors);
|
state
|
||||||
|
.config
|
||||||
|
.sync_from_monitor(state.layout.selected, &state.monitors);
|
||||||
state.tab = Tab::Config;
|
state.tab = Tab::Config;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -66,7 +71,8 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
if let Some(idx) = monitor_at(col, row, canvas, state) {
|
if let Some(idx) = monitor_at(col, row, canvas, state) {
|
||||||
let (min_x, min_y, _, _) = bounding_box(&state.monitors);
|
let (min_x, min_y, _, _) = bounding_box(&state.monitors);
|
||||||
let scale = canvas_scale_for(canvas, state);
|
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 (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
|
// Push undo at drag start, not on every move
|
||||||
state.push_undo();
|
state.push_undo();
|
||||||
|
|
@ -85,12 +91,19 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
let canvas = canvas_area(state.terminal_size);
|
let canvas = canvas_area(state.terminal_size);
|
||||||
let (min_x, min_y, _, _) = bounding_box(&state.monitors);
|
let (min_x, min_y, _, _) = bounding_box(&state.monitors);
|
||||||
let scale = canvas_scale_for(canvas, state);
|
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 (wx, wy) =
|
||||||
|
canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1);
|
||||||
|
|
||||||
let idx = drag.monitor_idx;
|
let idx = drag.monitor_idx;
|
||||||
let new_x = drag.origin_x + (wx - drag.click_world_x);
|
let new_x = drag.origin_x + (wx - drag.click_world_x);
|
||||||
let new_y = drag.origin_y + (wy - drag.click_world_y);
|
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);
|
let (sx, sy) = snap_position(
|
||||||
|
idx,
|
||||||
|
new_x,
|
||||||
|
new_y,
|
||||||
|
&state.monitors,
|
||||||
|
state.layout.snap_threshold,
|
||||||
|
);
|
||||||
state.monitors[idx].x = sx;
|
state.monitors[idx].x = sx;
|
||||||
state.monitors[idx].y = sy;
|
state.monitors[idx].y = sy;
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
|
|
@ -162,18 +175,31 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rect = Rect { x: cx, y: cy, width: cw, height: ch };
|
let rect = Rect {
|
||||||
|
x: cx,
|
||||||
|
y: cy,
|
||||||
|
width: cw,
|
||||||
|
height: ch,
|
||||||
|
};
|
||||||
|
|
||||||
let is_selected = i == selected;
|
let is_selected = i == selected;
|
||||||
let is_dragging = state.drag_state.as_ref().map(|d| d.monitor_idx == i).unwrap_or(false);
|
let is_dragging = state
|
||||||
|
.drag_state
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.monitor_idx == i)
|
||||||
|
.unwrap_or(false);
|
||||||
let is_overlapping = overlapping[i];
|
let is_overlapping = overlapping[i];
|
||||||
|
|
||||||
let border_style = if is_dragging {
|
let border_style = if is_dragging {
|
||||||
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Color::Magenta)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else if is_overlapping {
|
} else if is_overlapping {
|
||||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
|
||||||
} else if is_selected {
|
} else if is_selected {
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::Blue)
|
Style::default().fg(Color::Blue)
|
||||||
};
|
};
|
||||||
|
|
@ -190,7 +216,10 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
})
|
})
|
||||||
.border_style(border_style)
|
.border_style(border_style)
|
||||||
.title(Span::styled(&label, border_style))
|
.title(Span::styled(&label, border_style))
|
||||||
.title_bottom(Span::styled(&mode_str, Style::default().fg(Color::DarkGray)));
|
.title_bottom(Span::styled(
|
||||||
|
&mode_str,
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
|
||||||
f.render_widget(block, rect);
|
f.render_widget(block, rect);
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +232,9 @@ fn render_readout(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let idx = state.layout.selected.min(state.monitors.len() - 1);
|
let idx = state.layout.selected.min(state.monitors.len() - 1);
|
||||||
let m = &state.monitors[idx];
|
let m = &state.monitors[idx];
|
||||||
|
|
||||||
let mirror_info = m.mirror_of.as_ref()
|
let mirror_info = m
|
||||||
|
.mirror_of
|
||||||
|
.as_ref()
|
||||||
.map(|src| format!(" mirror:{}", src))
|
.map(|src| format!(" mirror:{}", src))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
|
@ -213,14 +244,24 @@ fn render_readout(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
|
|
||||||
let drag_hint = if state.drag_state.is_some() { " [dragging]" } else { "" };
|
let drag_hint = if state.drag_state.is_some() {
|
||||||
|
" [dragging]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
let text = format!(
|
let text = format!(
|
||||||
" {} x:{} y:{} {}x{}@{:.0}Hz scale:{:.2}{}{}{}",
|
" {} x:{} y:{} {}x{}@{:.0}Hz scale:{:.2}{}{}{}",
|
||||||
m.name, m.x, m.y,
|
m.name,
|
||||||
m.active_mode.width, m.active_mode.height, m.active_mode.refresh,
|
m.x,
|
||||||
|
m.y,
|
||||||
|
m.active_mode.width,
|
||||||
|
m.active_mode.height,
|
||||||
|
m.active_mode.refresh,
|
||||||
m.scale,
|
m.scale,
|
||||||
mirror_info, overlap_warn, drag_hint,
|
mirror_info,
|
||||||
|
overlap_warn,
|
||||||
|
drag_hint,
|
||||||
);
|
);
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(text).style(Style::default().fg(Color::Cyan)),
|
Paragraph::new(text).style(Style::default().fg(Color::Cyan)),
|
||||||
|
|
@ -235,8 +276,10 @@ fn overlapping_monitors(monitors: &[Monitor]) -> Vec<bool> {
|
||||||
for j in (i + 1)..monitors.len() {
|
for j in (i + 1)..monitors.len() {
|
||||||
let a = &monitors[i];
|
let a = &monitors[i];
|
||||||
let b = &monitors[j];
|
let b = &monitors[j];
|
||||||
if a.x < b.right_edge() && a.right_edge() > b.x
|
if a.x < b.right_edge()
|
||||||
&& a.y < b.bottom_edge() && a.bottom_edge() > b.y
|
&& a.right_edge() > b.x
|
||||||
|
&& a.y < b.bottom_edge()
|
||||||
|
&& a.bottom_edge() > b.y
|
||||||
{
|
{
|
||||||
flags[i] = true;
|
flags[i] = true;
|
||||||
flags[j] = true;
|
flags[j] = true;
|
||||||
|
|
@ -301,31 +344,13 @@ fn monitor_at(col: u16, row: u16, canvas: Rect, state: &AppState) -> Option<usiz
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn trigger_save(state: &mut AppState) {
|
pub fn trigger_save(state: &mut AppState) {
|
||||||
use crate::monitor::format_hypr_line;
|
match crate::store::save_from_monitors(&state.monitors) {
|
||||||
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(()) => {
|
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;
|
state.dirty = false;
|
||||||
|
state.set_status(
|
||||||
|
format!("Saved to {}", crate::store::config_path().display()),
|
||||||
|
StatusLevel::Success,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => state.set_status(format!("Save failed: {}", e), StatusLevel::Error),
|
Err(e) => state.set_status(format!("Save failed: {}", e), StatusLevel::Error),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ impl MirrorState {
|
||||||
fn next_field(&mut self) {
|
fn next_field(&mut self) {
|
||||||
// Skip Apply/Cancel if no result yet
|
// Skip Apply/Cancel if no result yet
|
||||||
let mut next = (self.focused + 1) % FIELDS.len();
|
let mut next = (self.focused + 1) % FIELDS.len();
|
||||||
if self.result.is_none() && (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel) {
|
if self.result.is_none()
|
||||||
|
&& (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel)
|
||||||
|
{
|
||||||
next = 0;
|
next = 0;
|
||||||
}
|
}
|
||||||
self.focused = next;
|
self.focused = next;
|
||||||
|
|
@ -54,8 +56,13 @@ impl MirrorState {
|
||||||
fn prev_field(&mut self) {
|
fn prev_field(&mut self) {
|
||||||
let len = FIELDS.len();
|
let len = FIELDS.len();
|
||||||
let mut prev = self.focused.checked_sub(1).unwrap_or(len - 1);
|
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) {
|
if self.result.is_none()
|
||||||
prev = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2);
|
&& (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel)
|
||||||
|
{
|
||||||
|
prev = FIELDS
|
||||||
|
.iter()
|
||||||
|
.position(|&f| f == MirrorField::Compute)
|
||||||
|
.unwrap_or(2);
|
||||||
}
|
}
|
||||||
self.focused = prev;
|
self.focused = prev;
|
||||||
}
|
}
|
||||||
|
|
@ -86,12 +93,14 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
}
|
}
|
||||||
KeyCode::Char('h') | KeyCode::Left => match state.mirror.current_field() {
|
KeyCode::Char('h') | KeyCode::Left => match state.mirror.current_field() {
|
||||||
MirrorField::Source => {
|
MirrorField::Source => {
|
||||||
state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
|
state.mirror.source_idx =
|
||||||
|
state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
state.mirror.fix_indices(count);
|
state.mirror.fix_indices(count);
|
||||||
state.mirror.result = None;
|
state.mirror.result = None;
|
||||||
}
|
}
|
||||||
MirrorField::Target => {
|
MirrorField::Target => {
|
||||||
state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
|
state.mirror.target_idx =
|
||||||
|
state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
state.mirror.fix_indices(count);
|
state.mirror.fix_indices(count);
|
||||||
state.mirror.result = None;
|
state.mirror.result = None;
|
||||||
}
|
}
|
||||||
|
|
@ -118,7 +127,10 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
Some(result) => {
|
Some(result) => {
|
||||||
state.mirror.result = Some(result);
|
state.mirror.result = Some(result);
|
||||||
// Move focus to Apply
|
// Move focus to Apply
|
||||||
state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3);
|
state.mirror.focused = FIELDS
|
||||||
|
.iter()
|
||||||
|
.position(|&f| f == MirrorField::Apply)
|
||||||
|
.unwrap_or(3);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
state.set_status(
|
state.set_status(
|
||||||
|
|
@ -143,9 +155,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
state.set_status(
|
state.set_status(
|
||||||
format!(
|
format!(
|
||||||
"Mirror set: {} → {} at {}",
|
"Mirror set: {} → {} at {}",
|
||||||
src_name,
|
src_name, state.monitors[tgt_idx].name, result.mirror_mode
|
||||||
state.monitors[tgt_idx].name,
|
|
||||||
result.mirror_mode
|
|
||||||
),
|
),
|
||||||
StatusLevel::Success,
|
StatusLevel::Success,
|
||||||
);
|
);
|
||||||
|
|
@ -182,17 +192,26 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
match row {
|
match row {
|
||||||
2 | 3 => {
|
2 | 3 => {
|
||||||
// Source picker area
|
// Source picker area
|
||||||
let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Source).unwrap_or(0);
|
let f_idx = FIELDS
|
||||||
|
.iter()
|
||||||
|
.position(|&f| f == MirrorField::Source)
|
||||||
|
.unwrap_or(0);
|
||||||
state.mirror.focused = f_idx;
|
state.mirror.focused = f_idx;
|
||||||
}
|
}
|
||||||
4 | 5 => {
|
4 | 5 => {
|
||||||
// Target picker area
|
// Target picker area
|
||||||
let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Target).unwrap_or(1);
|
let f_idx = FIELDS
|
||||||
|
.iter()
|
||||||
|
.position(|&f| f == MirrorField::Target)
|
||||||
|
.unwrap_or(1);
|
||||||
state.mirror.focused = f_idx;
|
state.mirror.focused = f_idx;
|
||||||
}
|
}
|
||||||
6 => {
|
6 => {
|
||||||
// Compute button
|
// Compute button
|
||||||
let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2);
|
let f_idx = FIELDS
|
||||||
|
.iter()
|
||||||
|
.position(|&f| f == MirrorField::Compute)
|
||||||
|
.unwrap_or(2);
|
||||||
state.mirror.focused = f_idx;
|
state.mirror.focused = f_idx;
|
||||||
// Also activate it
|
// Also activate it
|
||||||
let src = &state.monitors[state.mirror.source_idx];
|
let src = &state.monitors[state.mirror.source_idx];
|
||||||
|
|
@ -200,7 +219,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
match crate::mirror::find_mirror_modes(src, tgt) {
|
match crate::mirror::find_mirror_modes(src, tgt) {
|
||||||
Some(result) => {
|
Some(result) => {
|
||||||
state.mirror.result = Some(result);
|
state.mirror.result = Some(result);
|
||||||
state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3);
|
state.mirror.focused = FIELDS
|
||||||
|
.iter()
|
||||||
|
.position(|&f| f == MirrorField::Apply)
|
||||||
|
.unwrap_or(3);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
state.set_status(
|
state.set_status(
|
||||||
|
|
@ -218,7 +240,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
let col = event.column;
|
let col = event.column;
|
||||||
if col < 20 {
|
if col < 20 {
|
||||||
// Activate Apply
|
// Activate Apply
|
||||||
state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3);
|
state.mirror.focused = FIELDS
|
||||||
|
.iter()
|
||||||
|
.position(|&f| f == MirrorField::Apply)
|
||||||
|
.unwrap_or(3);
|
||||||
if let Some(result) = state.mirror.result.clone() {
|
if let Some(result) = state.mirror.result.clone() {
|
||||||
state.push_undo();
|
state.push_undo();
|
||||||
let src_name = state.monitors[state.mirror.source_idx].name.clone();
|
let src_name = state.monitors[state.mirror.source_idx].name.clone();
|
||||||
|
|
@ -229,7 +254,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
state.mirror.result = None;
|
state.mirror.result = None;
|
||||||
state.mirror.focused = 0;
|
state.mirror.focused = 0;
|
||||||
state.set_status(
|
state.set_status(
|
||||||
format!("Mirror set: {} → {} at {}", src_name, state.monitors[tgt_idx].name, result.mirror_mode),
|
format!(
|
||||||
|
"Mirror set: {} → {} at {}",
|
||||||
|
src_name, state.monitors[tgt_idx].name, result.mirror_mode
|
||||||
|
),
|
||||||
crate::ui::StatusLevel::Success,
|
crate::ui::StatusLevel::Success,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -246,33 +274,33 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
// Scroll in source/target pickers to cycle monitors
|
// Scroll in source/target pickers to cycle monitors
|
||||||
match state.mirror.current_field() {
|
match state.mirror.current_field() {
|
||||||
MirrorField::Source => {
|
MirrorField::Source => {
|
||||||
state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
|
state.mirror.source_idx =
|
||||||
|
state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
state.mirror.fix_indices(count);
|
state.mirror.fix_indices(count);
|
||||||
state.mirror.result = None;
|
state.mirror.result = None;
|
||||||
}
|
}
|
||||||
MirrorField::Target => {
|
MirrorField::Target => {
|
||||||
state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
|
state.mirror.target_idx =
|
||||||
|
state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
state.mirror.fix_indices(count);
|
state.mirror.fix_indices(count);
|
||||||
state.mirror.result = None;
|
state.mirror.result = None;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MouseEventKind::ScrollDown => {
|
MouseEventKind::ScrollDown => match state.mirror.current_field() {
|
||||||
match state.mirror.current_field() {
|
MirrorField::Source => {
|
||||||
MirrorField::Source => {
|
state.mirror.source_idx = (state.mirror.source_idx + 1) % count;
|
||||||
state.mirror.source_idx = (state.mirror.source_idx + 1) % count;
|
state.mirror.fix_indices(count);
|
||||||
state.mirror.fix_indices(count);
|
state.mirror.result = None;
|
||||||
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;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
MirrorField::Target => {
|
||||||
|
state.mirror.target_idx = (state.mirror.target_idx + 1) % count;
|
||||||
|
state.mirror.fix_indices(count);
|
||||||
|
state.mirror.result = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -324,12 +352,16 @@ fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) {
|
||||||
let focused = state.mirror.current_field();
|
let focused = state.mirror.current_field();
|
||||||
|
|
||||||
let src_style = if focused == MirrorField::Source {
|
let src_style = if focused == MirrorField::Source {
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::White)
|
Style::default().fg(Color::White)
|
||||||
};
|
};
|
||||||
let tgt_style = if focused == MirrorField::Target {
|
let tgt_style = if focused == MirrorField::Target {
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::White)
|
Style::default().fg(Color::White)
|
||||||
};
|
};
|
||||||
|
|
@ -354,7 +386,9 @@ fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) {
|
||||||
fn render_compute_btn(f: &mut Frame, area: Rect, state: &AppState) {
|
fn render_compute_btn(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
let focused = state.mirror.current_field() == MirrorField::Compute;
|
let focused = state.mirror.current_field() == MirrorField::Compute;
|
||||||
let style = if focused {
|
let style = if focused {
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::DarkGray)
|
||||||
};
|
};
|
||||||
|
|
@ -376,12 +410,16 @@ fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorRes
|
||||||
|
|
||||||
let focused = state.mirror.current_field();
|
let focused = state.mirror.current_field();
|
||||||
let apply_style = if focused == MirrorField::Apply {
|
let apply_style = if focused == MirrorField::Apply {
|
||||||
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::White)
|
Style::default().fg(Color::White)
|
||||||
};
|
};
|
||||||
let cancel_style = if focused == MirrorField::Cancel {
|
let cancel_style = if focused == MirrorField::Cancel {
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::DarkGray)
|
||||||
};
|
};
|
||||||
|
|
@ -397,7 +435,10 @@ fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorRes
|
||||||
Style::default().fg(Color::White),
|
Style::default().fg(Color::White),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" Refresh: {:.2} Hz ({})", result.refresh, refresh_label),
|
format!(
|
||||||
|
" Refresh: {:.2} Hz ({})",
|
||||||
|
result.refresh, refresh_label
|
||||||
|
),
|
||||||
Style::default().fg(Color::White),
|
Style::default().fg(Color::White),
|
||||||
)),
|
)),
|
||||||
Line::raw(""),
|
Line::raw(""),
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,7 @@ use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{layout::LayoutState, monitor::Monitor};
|
||||||
layout::LayoutState,
|
|
||||||
monitor::Monitor,
|
|
||||||
};
|
|
||||||
|
|
||||||
use config_view::ConfigState;
|
use config_view::ConfigState;
|
||||||
use mirror_view::MirrorState;
|
use mirror_view::MirrorState;
|
||||||
|
|
@ -141,7 +138,11 @@ impl AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_status(&mut self, text: impl Into<String>, level: StatusLevel) {
|
pub fn set_status(&mut self, text: impl Into<String>, level: StatusLevel) {
|
||||||
self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() });
|
self.status = Some(StatusMsg {
|
||||||
|
text: text.into(),
|
||||||
|
level,
|
||||||
|
born: Instant::now(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark the in-memory layout as edited. Also forgets `active_profile`
|
/// Mark the in-memory layout as edited. Also forgets `active_profile`
|
||||||
|
|
@ -163,7 +164,8 @@ impl AppState {
|
||||||
pub fn switch_tab(&mut self, tab: Tab) {
|
pub fn switch_tab(&mut self, tab: Tab) {
|
||||||
self.tab = tab;
|
self.tab = tab;
|
||||||
if tab == Tab::Config {
|
if tab == Tab::Config {
|
||||||
self.config.sync_from_monitor(self.layout.selected, &self.monitors);
|
self.config
|
||||||
|
.sync_from_monitor(self.layout.selected, &self.monitors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,10 +216,22 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) -> bool {
|
||||||
|
|
||||||
// Global tab switching
|
// Global tab switching
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Char('1') | KeyCode::F(1) => { state.switch_tab(Tab::Layout); return true; }
|
KeyCode::Char('1') | KeyCode::F(1) => {
|
||||||
KeyCode::Char('2') | KeyCode::F(2) => { state.switch_tab(Tab::Config); return true; }
|
state.switch_tab(Tab::Layout);
|
||||||
KeyCode::Char('3') | KeyCode::F(3) => { state.switch_tab(Tab::Mirror); return true; }
|
return true;
|
||||||
KeyCode::Char('4') | KeyCode::F(4) => { state.switch_tab(Tab::Profiles); 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;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,10 @@ fn handle_list_key(event: KeyEvent, state: &mut AppState) {
|
||||||
match profile::delete(&name) {
|
match profile::delete(&name) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
state.profiles.refresh();
|
state.profiles.refresh();
|
||||||
state.set_status(format!("Deleted profile '{}'", name), StatusLevel::Success);
|
state.set_status(
|
||||||
|
format!("Deleted profile '{}'", name),
|
||||||
|
StatusLevel::Success,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
state.set_status(format!("Delete failed: {}", e), StatusLevel::Error);
|
state.set_status(format!("Delete failed: {}", e), StatusLevel::Error);
|
||||||
|
|
@ -195,8 +198,11 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
MouseEventKind::ScrollUp => {
|
MouseEventKind::ScrollUp => {
|
||||||
let count = state.profiles.profiles.len();
|
let count = state.profiles.profiles.len();
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
state.profiles.selected_idx =
|
state.profiles.selected_idx = state
|
||||||
state.profiles.selected_idx.checked_sub(1).unwrap_or(count - 1);
|
.profiles
|
||||||
|
.selected_idx
|
||||||
|
.checked_sub(1)
|
||||||
|
.unwrap_or(count - 1);
|
||||||
state.profiles.focused = ProfileField::List;
|
state.profiles.focused = ProfileField::List;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +314,9 @@ fn render_list(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD)
|
||||||
.bg(Color::DarkGray)
|
.bg(Color::DarkGray)
|
||||||
} else if is_selected {
|
} else if is_selected {
|
||||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::White)
|
Style::default().fg(Color::White)
|
||||||
};
|
};
|
||||||
|
|
@ -362,22 +370,28 @@ fn render_save_row(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::DarkGray)
|
||||||
};
|
};
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(input_display)
|
Paragraph::new(input_display).style(input_style).block(
|
||||||
.style(input_style)
|
Block::default()
|
||||||
.block(Block::default().borders(Borders::ALL).border_style(input_style)),
|
.borders(Borders::ALL)
|
||||||
|
.border_style(input_style),
|
||||||
|
),
|
||||||
chunks[0],
|
chunks[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save button
|
// Save button
|
||||||
let save_style = if save_focused {
|
let save_style = if save_focused {
|
||||||
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::DarkGray)
|
||||||
};
|
};
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(" [ Save ] ")
|
Paragraph::new(" [ Save ] ").style(save_style).block(
|
||||||
.style(save_style)
|
Block::default()
|
||||||
.block(Block::default().borders(Borders::ALL).border_style(save_style)),
|
.borders(Borders::ALL)
|
||||||
|
.border_style(save_style),
|
||||||
|
),
|
||||||
chunks[1],
|
chunks[1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue