Compare commits
No commits in common. "6fda461133dde4f2d6b2124510a28147fc9065e4" and "40d28d3ec4a864ff805c9a1e8f7bd60b2c39407b" have entirely different histories.
6fda461133
...
40d28d3ec4
6 changed files with 39 additions and 138 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -35,6 +35,3 @@ CLAUDE.md
|
||||||
|
|
||||||
# graphify knowledge-graph output (local tool cache, not for commit)
|
# graphify knowledge-graph output (local tool cache, not for commit)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
|
|
||||||
# .freebuff local tool state (not for commit)
|
|
||||||
.freebuff/
|
|
||||||
|
|
|
||||||
10
src/main.rs
10
src/main.rs
|
|
@ -173,20 +173,12 @@ async fn run(
|
||||||
}
|
}
|
||||||
crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await {
|
crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await {
|
||||||
Ok(monitors) => {
|
Ok(monitors) => {
|
||||||
if state.dirty {
|
|
||||||
// Don't clobber unsaved edits (or silently drop the
|
|
||||||
// unsaved-changes quit guard) on a refresh.
|
|
||||||
state.set_status(
|
|
||||||
"Refresh skipped: unsaved changes present.",
|
|
||||||
StatusLevel::Info,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
state.monitors = monitors;
|
state.monitors = monitors;
|
||||||
state.layout.clamp_selected(state.monitors.len());
|
state.layout.clamp_selected(state.monitors.len());
|
||||||
|
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) => {
|
Err(e) => {
|
||||||
state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error);
|
state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,39 +129,15 @@ pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chrono_now() -> String {
|
fn chrono_now() -> String {
|
||||||
// In-process ISO 8601 (UTC) timestamp — no chrono crate, and no shelling
|
// Simple ISO 8601 timestamp without pulling in chrono
|
||||||
// out to `date`. `civil_from_days` is the Hinnant days-from-civil epoch
|
// Uses date command; falls back to a placeholder if unavailable
|
||||||
// algorithm. Falls back to the Unix epoch instant if the clock is broken.
|
std::process::Command::new("date")
|
||||||
let secs = std::time::SystemTime::now()
|
.arg("+%Y-%m-%dT%H:%M:%SZ")
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.output()
|
||||||
.map(|d| d.as_secs())
|
.ok()
|
||||||
.unwrap_or(0);
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
let (h, m, s) = secs_of_day(secs % 86_400);
|
.map(|s| s.trim().to_owned())
|
||||||
let (y, mo, d) = civil_from_days((secs / 86_400) as i64);
|
.unwrap_or_else(|| "unknown".to_owned())
|
||||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert days since 1970-01-01 to a (year, month, day) civil date.
|
|
||||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
|
||||||
let z = z + 719_468;
|
|
||||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
||||||
let doe = (z - era * 146_097) as u64;
|
|
||||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
||||||
let y = yoe as i64 + era * 400;
|
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
||||||
let mp = (5 * doy + 2) / 153;
|
|
||||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
|
||||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
|
||||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seconds within the day -> (hours, minutes, seconds).
|
|
||||||
fn secs_of_day(secs: u64) -> (u32, u32, u32) {
|
|
||||||
(
|
|
||||||
((secs / 3600) % 24) as u32,
|
|
||||||
((secs / 60) % 60) as u32,
|
|
||||||
(secs % 60) as u32,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -208,19 +184,4 @@ mod tests {
|
||||||
assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00");
|
assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00");
|
||||||
assert_eq!(deserialized.monitors[1].x, 1920);
|
assert_eq!(deserialized.monitors[1].x, 1920);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chrono_now_helpers() {
|
|
||||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
|
||||||
// 2024-01-01 is epoch day 19723.
|
|
||||||
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
|
|
||||||
assert_eq!(secs_of_day(0), (0, 0, 0));
|
|
||||||
assert_eq!(secs_of_day(86_399), (23, 59, 59));
|
|
||||||
// Spot-check the formatted output shape.
|
|
||||||
let s = chrono_now();
|
|
||||||
assert_eq!(s.len(), 20);
|
|
||||||
|
|
||||||
assert!(s.ends_with('Z'));
|
|
||||||
assert!(s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,30 +133,27 @@ impl ConfigState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
|
let cfg = &mut state.config;
|
||||||
|
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Char('j') | KeyCode::Down => {
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
state.clear_burst();
|
cfg.scale_editing = false;
|
||||||
state.config.scale_editing = false;
|
cfg.next_field();
|
||||||
state.config.next_field();
|
|
||||||
}
|
}
|
||||||
KeyCode::Char('k') | KeyCode::Up => {
|
KeyCode::Char('k') | KeyCode::Up => {
|
||||||
state.clear_burst();
|
cfg.scale_editing = false;
|
||||||
state.config.scale_editing = false;
|
cfg.prev_field();
|
||||||
state.config.prev_field();
|
|
||||||
}
|
}
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
state.clear_burst();
|
cfg.scale_editing = false;
|
||||||
state.config.scale_editing = false;
|
cfg.next_field();
|
||||||
state.config.next_field();
|
|
||||||
}
|
}
|
||||||
KeyCode::BackTab => {
|
KeyCode::BackTab => {
|
||||||
state.clear_burst();
|
cfg.scale_editing = false;
|
||||||
state.config.scale_editing = false;
|
cfg.prev_field();
|
||||||
state.config.prev_field();
|
|
||||||
}
|
}
|
||||||
// Navigate between monitors
|
// Navigate between monitors
|
||||||
KeyCode::Char('[') => {
|
KeyCode::Char('[') => {
|
||||||
state.clear_burst();
|
|
||||||
let count = state.monitors.len();
|
let count = state.monitors.len();
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1);
|
let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1);
|
||||||
|
|
@ -165,7 +162,6 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char(']') => {
|
KeyCode::Char(']') => {
|
||||||
state.clear_burst();
|
|
||||||
let count = state.monitors.len();
|
let count = state.monitors.len();
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
let new_idx = (state.config.monitor_idx + 1) % count;
|
let new_idx = (state.config.monitor_idx + 1) % count;
|
||||||
|
|
@ -180,20 +176,19 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
crate::ui::layout_view::trigger_save(state);
|
crate::ui::layout_view::trigger_save(state);
|
||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
state.clear_burst();
|
|
||||||
state.config.scale_editing = false;
|
state.config.scale_editing = false;
|
||||||
// Re-sync from live monitor to discard pending edits
|
// Re-sync from live monitor to discard pending edits
|
||||||
let idx = state.config.monitor_idx;
|
let idx = state.config.monitor_idx;
|
||||||
state.config.sync_from_monitor(idx, &state.monitors);
|
state.config.sync_from_monitor(idx, &state.monitors);
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
state.clear_burst();
|
|
||||||
if state.config.current_field() == ConfigField::Scale {
|
if state.config.current_field() == ConfigField::Scale {
|
||||||
commit_scale(state);
|
commit_scale(state);
|
||||||
}
|
}
|
||||||
apply_current(state);
|
apply_current(state);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
state.push_undo();
|
||||||
handle_field_key(event, state);
|
handle_field_key(event, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -219,10 +214,12 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MouseEventKind::ScrollUp => {
|
MouseEventKind::ScrollUp => {
|
||||||
|
state.push_undo();
|
||||||
let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
|
let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
|
||||||
handle_field_key(fake_right, state);
|
handle_field_key(fake_right, state);
|
||||||
}
|
}
|
||||||
MouseEventKind::ScrollDown => {
|
MouseEventKind::ScrollDown => {
|
||||||
|
state.push_undo();
|
||||||
let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
|
let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
|
||||||
handle_field_key(fake_left, state);
|
handle_field_key(fake_left, state);
|
||||||
}
|
}
|
||||||
|
|
@ -236,8 +233,6 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let idx = state.config.monitor_idx.min(monitors_len - 1);
|
let idx = state.config.monitor_idx.min(monitors_len - 1);
|
||||||
// Coalesce consecutive value cycles (and scroll) into one undo step.
|
|
||||||
state.micro_edit();
|
|
||||||
|
|
||||||
match state.config.current_field() {
|
match state.config.current_field() {
|
||||||
ConfigField::Resolution => match event.code {
|
ConfigField::Resolution => match event.code {
|
||||||
|
|
@ -399,12 +394,9 @@ fn commit_scale(state: &mut AppState) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_current(state: &mut AppState) {
|
fn apply_current(state: &mut AppState) {
|
||||||
state.clear_burst();
|
|
||||||
state.pending_apply = true;
|
state.pending_apply = true;
|
||||||
state.set_status("Applying...", StatusLevel::Info);
|
state.set_status("Applying...", StatusLevel::Info);
|
||||||
// Don't clear `dirty` here: it must survive until the apply actually
|
state.dirty = false;
|
||||||
// succeeds (main.rs clears it on a successful apply + save). Otherwise a
|
|
||||||
// failed `hyprctl` apply would silently drop the unsaved-changes guard.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
|
@ -440,6 +432,7 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
);
|
);
|
||||||
|
|
||||||
let form_area = chunks[1];
|
let form_area = chunks[1];
|
||||||
|
let row_height = 1u16;
|
||||||
let fields = ConfigField::ALL;
|
let fields = ConfigField::ALL;
|
||||||
|
|
||||||
let items: Vec<ListItem> = fields
|
let items: Vec<ListItem> = fields
|
||||||
|
|
@ -461,6 +454,7 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let _ = row_height; // used implicitly via ListItem heights
|
||||||
let list = List::new(items).block(
|
let list = List::new(items).block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
|
|
|
||||||
|
|
@ -23,48 +23,35 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||||
|
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Char('h') | KeyCode::Left => {
|
KeyCode::Char('h') | KeyCode::Left => {
|
||||||
state.micro_edit();
|
state.push_undo();
|
||||||
move_selected(&state.layout, &mut state.monitors, -step, 0);
|
move_selected(&state.layout, &mut state.monitors, -step, 0);
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
KeyCode::Char('l') | KeyCode::Right => {
|
KeyCode::Char('l') | KeyCode::Right => {
|
||||||
state.micro_edit();
|
state.push_undo();
|
||||||
move_selected(&state.layout, &mut state.monitors, step, 0);
|
move_selected(&state.layout, &mut state.monitors, step, 0);
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
KeyCode::Char('k') | KeyCode::Up => {
|
KeyCode::Char('k') | KeyCode::Up => {
|
||||||
state.micro_edit();
|
state.push_undo();
|
||||||
move_selected(&state.layout, &mut state.monitors, 0, -step);
|
move_selected(&state.layout, &mut state.monitors, 0, -step);
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
KeyCode::Char('j') | KeyCode::Down => {
|
KeyCode::Char('j') | KeyCode::Down => {
|
||||||
state.micro_edit();
|
state.push_undo();
|
||||||
move_selected(&state.layout, &mut state.monitors, 0, step);
|
move_selected(&state.layout, &mut state.monitors, 0, step);
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
KeyCode::Tab | KeyCode::Char('n') => {
|
KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count),
|
||||||
state.clear_burst();
|
KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(count),
|
||||||
state.layout.next(count);
|
KeyCode::Char('[') => state.layout.zoom = (state.layout.zoom - 0.1).max(0.1),
|
||||||
}
|
KeyCode::Char(']') => state.layout.zoom = (state.layout.zoom + 0.1).min(5.0),
|
||||||
KeyCode::BackTab | KeyCode::Char('p') => {
|
|
||||||
state.clear_burst();
|
|
||||||
state.layout.prev(count);
|
|
||||||
}
|
|
||||||
KeyCode::Char('[') => {
|
|
||||||
state.clear_burst();
|
|
||||||
state.layout.zoom = (state.layout.zoom - 0.1).max(0.1);
|
|
||||||
}
|
|
||||||
KeyCode::Char(']') => {
|
|
||||||
state.clear_burst();
|
|
||||||
state.layout.zoom = (state.layout.zoom + 0.1).min(5.0);
|
|
||||||
}
|
|
||||||
KeyCode::Char('0') => {
|
KeyCode::Char('0') => {
|
||||||
state.push_undo();
|
state.push_undo();
|
||||||
auto_arrange(&mut state.monitors);
|
auto_arrange(&mut state.monitors);
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
state.clear_burst();
|
|
||||||
state
|
state
|
||||||
.config
|
.config
|
||||||
.sync_from_monitor(state.layout.selected, &state.monitors);
|
.sync_from_monitor(state.layout.selected, &state.monitors);
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,6 @@ pub struct AppState {
|
||||||
pub active_profile: Option<String>,
|
pub active_profile: Option<String>,
|
||||||
/// Snapshots for Ctrl+Z undo (up to 20 deep).
|
/// Snapshots for Ctrl+Z undo (up to 20 deep).
|
||||||
pub undo_stack: Vec<Vec<Monitor>>,
|
pub undo_stack: Vec<Vec<Monitor>>,
|
||||||
/// True while a run of small incremental edits (nudges / value cycles)
|
|
||||||
/// is ongoing, so undo coalesces the whole burst into one snapshot.
|
|
||||||
undo_in_burst: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
|
@ -137,7 +134,6 @@ impl AppState {
|
||||||
pending_apply: false,
|
pending_apply: false,
|
||||||
active_profile: None,
|
active_profile: None,
|
||||||
undo_stack: Vec::new(),
|
undo_stack: Vec::new(),
|
||||||
undo_in_burst: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,7 +162,6 @@ impl AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn switch_tab(&mut self, tab: Tab) {
|
pub fn switch_tab(&mut self, tab: Tab) {
|
||||||
self.undo_in_burst = false;
|
|
||||||
self.tab = tab;
|
self.tab = tab;
|
||||||
if tab == Tab::Config {
|
if tab == Tab::Config {
|
||||||
self.config
|
self.config
|
||||||
|
|
@ -174,32 +169,8 @@ impl AppState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a monitor snapshot for undo (max 20 entries) and end any
|
/// Save a monitor snapshot for undo (max 20 entries).
|
||||||
/// in-progress edit burst.
|
|
||||||
pub fn push_undo(&mut self) {
|
pub fn push_undo(&mut self) {
|
||||||
self.push_snapshot();
|
|
||||||
self.undo_in_burst = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start (or continue) a run of small incremental edits. Only the first
|
|
||||||
/// edit in the run actually snapshots, so nudging a monitor 20 px (or
|
|
||||||
/// cycling a value repeatedly) collapses to a single undo step rather
|
|
||||||
/// than consuming 20 of the 20-step undo stack.
|
|
||||||
pub fn micro_edit(&mut self) {
|
|
||||||
if !self.undo_in_burst {
|
|
||||||
self.push_snapshot();
|
|
||||||
self.undo_in_burst = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End a coalesced-edit burst without snapping. Called on navigation
|
|
||||||
/// (tab switches, monitor/field changes, zoom) so bursts don't bleed
|
|
||||||
/// across distinct actions.
|
|
||||||
pub fn clear_burst(&mut self) {
|
|
||||||
self.undo_in_burst = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_snapshot(&mut self) {
|
|
||||||
self.undo_stack.push(self.monitors.clone());
|
self.undo_stack.push(self.monitors.clone());
|
||||||
if self.undo_stack.len() > 20 {
|
if self.undo_stack.len() > 20 {
|
||||||
self.undo_stack.remove(0);
|
self.undo_stack.remove(0);
|
||||||
|
|
@ -210,7 +181,6 @@ impl AppState {
|
||||||
pub fn undo(&mut self) {
|
pub fn undo(&mut self) {
|
||||||
if let Some(snapshot) = self.undo_stack.pop() {
|
if let Some(snapshot) = self.undo_stack.pop() {
|
||||||
self.monitors = snapshot;
|
self.monitors = snapshot;
|
||||||
self.undo_in_burst = false;
|
|
||||||
self.mark_dirty();
|
self.mark_dirty();
|
||||||
self.layout.clamp_selected(self.monitors.len());
|
self.layout.clamp_selected(self.monitors.len());
|
||||||
// Re-sync config view to the restored state
|
// Re-sync config view to the restored state
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue