breadmon/src/ui/mirror_view.rs
Breadway d98b5b4fad
Some checks failed
dev release / build (push) Failing after 0s
Share ~/.config/hypr/monitors.json with the BOS Display panel
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.
2026-08-15 22:53:28 +08:00

461 lines
16 KiB
Rust

use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::{
mirror::{find_mirror_modes, refresh_match_label, MirrorResult},
ui::{AppState, StatusLevel},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MirrorField {
Source,
Target,
Compute,
Apply,
Cancel,
}
#[derive(Debug, Default)]
pub struct MirrorState {
pub source_idx: usize,
pub target_idx: usize,
pub result: Option<MirrorResult>,
pub focused: usize, // index into FIELDS
}
const FIELDS: &[MirrorField] = &[
MirrorField::Source,
MirrorField::Target,
MirrorField::Compute,
MirrorField::Apply,
MirrorField::Cancel,
];
impl MirrorState {
fn current_field(&self) -> MirrorField {
FIELDS[self.focused.min(FIELDS.len() - 1)]
}
fn next_field(&mut self) {
// Skip Apply/Cancel if no result yet
let mut next = (self.focused + 1) % FIELDS.len();
if self.result.is_none()
&& (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel)
{
next = 0;
}
self.focused = next;
}
fn prev_field(&mut self) {
let len = FIELDS.len();
let mut prev = self.focused.checked_sub(1).unwrap_or(len - 1);
if self.result.is_none()
&& (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel)
{
prev = FIELDS
.iter()
.position(|&f| f == MirrorField::Compute)
.unwrap_or(2);
}
self.focused = prev;
}
/// Ensure source and target are different
pub fn fix_indices(&mut self, count: usize) {
if count < 2 {
return;
}
if self.source_idx == self.target_idx {
self.target_idx = (self.source_idx + 1) % count;
}
}
}
pub fn handle_key(event: KeyEvent, state: &mut AppState) {
let count = state.monitors.len();
if count < 2 {
return;
}
match event.code {
KeyCode::Tab | KeyCode::Char('j') | KeyCode::Down => {
state.mirror.next_field();
}
KeyCode::BackTab | KeyCode::Char('k') | KeyCode::Up => {
state.mirror.prev_field();
}
KeyCode::Char('h') | KeyCode::Left => match state.mirror.current_field() {
MirrorField::Source => {
state.mirror.source_idx =
state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
state.mirror.fix_indices(count);
state.mirror.result = None;
}
MirrorField::Target => {
state.mirror.target_idx =
state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
state.mirror.fix_indices(count);
state.mirror.result = None;
}
_ => {}
},
KeyCode::Char('l') | KeyCode::Right => match state.mirror.current_field() {
MirrorField::Source => {
state.mirror.source_idx = (state.mirror.source_idx + 1) % count;
state.mirror.fix_indices(count);
state.mirror.result = None;
}
MirrorField::Target => {
state.mirror.target_idx = (state.mirror.target_idx + 1) % count;
state.mirror.fix_indices(count);
state.mirror.result = None;
}
_ => {}
},
KeyCode::Enter => match state.mirror.current_field() {
MirrorField::Compute => {
let src = &state.monitors[state.mirror.source_idx];
let tgt = &state.monitors[state.mirror.target_idx];
match find_mirror_modes(src, tgt) {
Some(result) => {
state.mirror.result = Some(result);
// Move focus to Apply
state.mirror.focused = FIELDS
.iter()
.position(|&f| f == MirrorField::Apply)
.unwrap_or(3);
}
None => {
state.set_status(
"No compatible modes found between these monitors.",
StatusLevel::Error,
);
}
}
}
MirrorField::Apply => {
if let Some(result) = state.mirror.result.clone() {
state.push_undo();
let src_name = state.monitors[state.mirror.source_idx].name.clone();
let tgt_idx = state.mirror.target_idx;
state.monitors[tgt_idx].active_mode = result.mirror_mode.clone();
state.monitors[tgt_idx].mirror_of = Some(src_name.clone());
state.mark_dirty();
state.mirror.result = None;
state.mirror.focused = 0;
state.set_status(
format!(
"Mirror set: {}{} at {}",
src_name, state.monitors[tgt_idx].name, result.mirror_mode
),
StatusLevel::Success,
);
}
}
MirrorField::Cancel => {
state.mirror.result = None;
state.mirror.focused = 0;
}
_ => {}
},
KeyCode::Esc => {
state.mirror.result = None;
state.mirror.focused = 0;
}
_ => {}
}
}
pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
let count = state.monitors.len();
if count < 2 {
return;
}
let row = event.row;
// Content area starts at y=2.
// Pickers chunk: y=2..5 (height 4). Source line at y=2, target at y=4.
// Compute button: y=6.
// Result panel: y=7+ (border at y=7, buttons at ~y=13 for a typical terminal).
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
match row {
2 | 3 => {
// Source picker area
let f_idx = FIELDS
.iter()
.position(|&f| f == MirrorField::Source)
.unwrap_or(0);
state.mirror.focused = f_idx;
}
4 | 5 => {
// Target picker area
let f_idx = FIELDS
.iter()
.position(|&f| f == MirrorField::Target)
.unwrap_or(1);
state.mirror.focused = f_idx;
}
6 => {
// Compute button
let f_idx = FIELDS
.iter()
.position(|&f| f == MirrorField::Compute)
.unwrap_or(2);
state.mirror.focused = f_idx;
// Also activate it
let src = &state.monitors[state.mirror.source_idx];
let tgt = &state.monitors[state.mirror.target_idx];
match crate::mirror::find_mirror_modes(src, tgt) {
Some(result) => {
state.mirror.result = Some(result);
state.mirror.focused = FIELDS
.iter()
.position(|&f| f == MirrorField::Apply)
.unwrap_or(3);
}
None => {
state.set_status(
"No compatible modes found between these monitors.",
crate::ui::StatusLevel::Error,
);
}
}
}
r if r >= 7
// Result panel: Apply is on the line with buttons.
// Rough column check: col < 20 = Apply, col >= 20 = Cancel
&& state.mirror.result.is_some() =>
{
let col = event.column;
if col < 20 {
// Activate Apply
state.mirror.focused = FIELDS
.iter()
.position(|&f| f == MirrorField::Apply)
.unwrap_or(3);
if let Some(result) = state.mirror.result.clone() {
state.push_undo();
let src_name = state.monitors[state.mirror.source_idx].name.clone();
let tgt_idx = state.mirror.target_idx;
state.monitors[tgt_idx].active_mode = result.mirror_mode.clone();
state.monitors[tgt_idx].mirror_of = Some(src_name.clone());
state.mark_dirty();
state.mirror.result = None;
state.mirror.focused = 0;
state.set_status(
format!(
"Mirror set: {}{} at {}",
src_name, state.monitors[tgt_idx].name, result.mirror_mode
),
crate::ui::StatusLevel::Success,
);
}
} else {
// Cancel
state.mirror.result = None;
state.mirror.focused = 0;
}
}
_ => {}
}
}
MouseEventKind::ScrollUp => {
// Scroll in source/target pickers to cycle monitors
match state.mirror.current_field() {
MirrorField::Source => {
state.mirror.source_idx =
state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
state.mirror.fix_indices(count);
state.mirror.result = None;
}
MirrorField::Target => {
state.mirror.target_idx =
state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
state.mirror.fix_indices(count);
state.mirror.result = None;
}
_ => {}
}
}
MouseEventKind::ScrollDown => match state.mirror.current_field() {
MirrorField::Source => {
state.mirror.source_idx = (state.mirror.source_idx + 1) % count;
state.mirror.fix_indices(count);
state.mirror.result = None;
}
MirrorField::Target => {
state.mirror.target_idx = (state.mirror.target_idx + 1) % count;
state.mirror.fix_indices(count);
state.mirror.result = None;
}
_ => {}
},
_ => {}
}
}
pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
let count = state.monitors.len();
if count < 2 {
f.render_widget(
Paragraph::new("Need at least 2 monitors for mirror setup.")
.style(Style::default().fg(Color::DarkGray)),
area,
);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4), // pickers
Constraint::Length(1), // compute button
Constraint::Min(0), // result / hint
])
.split(area);
render_pickers(f, chunks[0], state, count);
render_compute_btn(f, chunks[1], state);
if let Some(ref result) = state.mirror.result {
render_result(f, chunks[2], state, result);
} else {
let hint = Paragraph::new(" Press Enter on [Compute] to find the best matching modes.")
.style(Style::default().fg(Color::DarkGray));
f.render_widget(hint, chunks[2]);
}
}
fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) {
let src_name = state
.monitors
.get(state.mirror.source_idx.min(count - 1))
.map(|m| m.name.as_str())
.unwrap_or("?");
let tgt_name = state
.monitors
.get(state.mirror.target_idx.min(count - 1))
.map(|m| m.name.as_str())
.unwrap_or("?");
let focused = state.mirror.current_field();
let src_style = if focused == MirrorField::Source {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let tgt_style = if focused == MirrorField::Target {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let lines = vec![
Line::from(vec![
Span::raw(" Source "),
Span::styled(format!("[ {} ]", src_name), src_style),
Span::raw(" (mirror this output)"),
]),
Line::raw(""),
Line::from(vec![
Span::raw(" Target "),
Span::styled(format!("[ {} ]", tgt_name), tgt_style),
Span::raw(" (the mirroring output)"),
]),
];
f.render_widget(Paragraph::new(lines), area);
}
fn render_compute_btn(f: &mut Frame, area: Rect, state: &AppState) {
let focused = state.mirror.current_field() == MirrorField::Compute;
let style = if focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
f.render_widget(
Paragraph::new(" [ Compute Best Match ]").style(style),
area,
);
}
fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorResult) {
let ar = result.ar_ratio;
let ar_label = if result.ar_exact {
format!("{}:{} (exact)", ar.0, ar.1)
} else {
format!("{}:{} (approx)", ar.0, ar.1)
};
let refresh_label = refresh_match_label(result);
let focused = state.mirror.current_field();
let apply_style = if focused == MirrorField::Apply {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let cancel_style = if focused == MirrorField::Cancel {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let lines = vec![
Line::raw(""),
Line::from(Span::styled(
format!(" Source mode: {} (AR {})", result.source_mode, ar_label),
Style::default().fg(Color::White),
)),
Line::from(Span::styled(
format!(" Mirror mode: {}", result.mirror_mode),
Style::default().fg(Color::White),
)),
Line::from(Span::styled(
format!(
" Refresh: {:.2} Hz ({})",
result.refresh, refresh_label
),
Style::default().fg(Color::White),
)),
Line::raw(""),
Line::from(vec![
Span::raw(" "),
Span::styled("[ Apply Mirror ]", apply_style),
Span::raw(" "),
Span::styled("[ Cancel ]", cancel_style),
]),
];
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(" Result ");
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(Paragraph::new(lines), inner);
}