Initial commit
This commit is contained in:
commit
c5136331b4
16 changed files with 4556 additions and 0 deletions
420
src/ui/mirror_view.rs
Normal file
420
src/ui/mirror_view.rs
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
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.dirty = true;
|
||||
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
|
||||
if 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.dirty = true;
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue