Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fda461133 | ||
|
|
8e0b95899c | ||
|
|
40d28d3ec4 |
7 changed files with 154 additions and 40 deletions
|
|
@ -17,7 +17,16 @@ jobs:
|
|||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: build
|
||||
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -f src/ci/build.sh ]; then
|
||||
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
|
||||
exit 1
|
||||
fi
|
||||
cd src && bash ci/build.sh cargo build --release --locked || {
|
||||
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
|
|
@ -33,8 +42,14 @@ jobs:
|
|||
ln -sfn "${VERSION}" "/srv/breadway-dl/breadmon/latest"
|
||||
|
||||
- name: regenerate index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/bread-ecosystem-ci
|
||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -35,3 +35,6 @@ CLAUDE.md
|
|||
|
||||
# graphify knowledge-graph output (local tool cache, not for commit)
|
||||
graphify-out/
|
||||
|
||||
# .freebuff local tool state (not for commit)
|
||||
.freebuff/
|
||||
|
|
|
|||
18
src/main.rs
18
src/main.rs
|
|
@ -173,11 +173,19 @@ async fn run(
|
|||
}
|
||||
crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await {
|
||||
Ok(monitors) => {
|
||||
state.monitors = monitors;
|
||||
state.layout.clamp_selected(state.monitors.len());
|
||||
state.dirty = false;
|
||||
state.active_profile = None;
|
||||
state.set_status("Monitors refreshed.", StatusLevel::Success);
|
||||
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.layout.clamp_selected(state.monitors.len());
|
||||
state.active_profile = None;
|
||||
state.set_status("Monitors refreshed.", StatusLevel::Success);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error);
|
||||
|
|
|
|||
|
|
@ -129,15 +129,39 @@ pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) {
|
|||
}
|
||||
|
||||
fn chrono_now() -> String {
|
||||
// Simple ISO 8601 timestamp without pulling in chrono
|
||||
// Uses date command; falls back to a placeholder if unavailable
|
||||
std::process::Command::new("date")
|
||||
.arg("+%Y-%m-%dT%H:%M:%SZ")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_owned())
|
||||
.unwrap_or_else(|| "unknown".to_owned())
|
||||
// In-process ISO 8601 (UTC) timestamp — no chrono crate, and no shelling
|
||||
// out to `date`. `civil_from_days` is the Hinnant days-from-civil epoch
|
||||
// algorithm. Falls back to the Unix epoch instant if the clock is broken.
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let (h, m, s) = secs_of_day(secs % 86_400);
|
||||
let (y, mo, d) = civil_from_days((secs / 86_400) as i64);
|
||||
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)]
|
||||
|
|
@ -184,4 +208,19 @@ mod tests {
|
|||
assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00");
|
||||
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,27 +133,30 @@ impl ConfigState {
|
|||
}
|
||||
|
||||
pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||
let cfg = &mut state.config;
|
||||
|
||||
match event.code {
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
cfg.scale_editing = false;
|
||||
cfg.next_field();
|
||||
state.clear_burst();
|
||||
state.config.scale_editing = false;
|
||||
state.config.next_field();
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
cfg.scale_editing = false;
|
||||
cfg.prev_field();
|
||||
state.clear_burst();
|
||||
state.config.scale_editing = false;
|
||||
state.config.prev_field();
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
cfg.scale_editing = false;
|
||||
cfg.next_field();
|
||||
state.clear_burst();
|
||||
state.config.scale_editing = false;
|
||||
state.config.next_field();
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
cfg.scale_editing = false;
|
||||
cfg.prev_field();
|
||||
state.clear_burst();
|
||||
state.config.scale_editing = false;
|
||||
state.config.prev_field();
|
||||
}
|
||||
// Navigate between monitors
|
||||
KeyCode::Char('[') => {
|
||||
state.clear_burst();
|
||||
let count = state.monitors.len();
|
||||
if count > 0 {
|
||||
let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
|
|
@ -162,6 +165,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
|||
}
|
||||
}
|
||||
KeyCode::Char(']') => {
|
||||
state.clear_burst();
|
||||
let count = state.monitors.len();
|
||||
if count > 0 {
|
||||
let new_idx = (state.config.monitor_idx + 1) % count;
|
||||
|
|
@ -176,19 +180,20 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
|||
crate::ui::layout_view::trigger_save(state);
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
state.clear_burst();
|
||||
state.config.scale_editing = false;
|
||||
// Re-sync from live monitor to discard pending edits
|
||||
let idx = state.config.monitor_idx;
|
||||
state.config.sync_from_monitor(idx, &state.monitors);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
state.clear_burst();
|
||||
if state.config.current_field() == ConfigField::Scale {
|
||||
commit_scale(state);
|
||||
}
|
||||
apply_current(state);
|
||||
}
|
||||
_ => {
|
||||
state.push_undo();
|
||||
handle_field_key(event, state);
|
||||
}
|
||||
}
|
||||
|
|
@ -214,12 +219,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
}
|
||||
}
|
||||
MouseEventKind::ScrollUp => {
|
||||
state.push_undo();
|
||||
let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
|
||||
handle_field_key(fake_right, state);
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
state.push_undo();
|
||||
let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
|
||||
handle_field_key(fake_left, state);
|
||||
}
|
||||
|
|
@ -233,6 +236,8 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
|
|||
return;
|
||||
}
|
||||
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() {
|
||||
ConfigField::Resolution => match event.code {
|
||||
|
|
@ -394,9 +399,12 @@ fn commit_scale(state: &mut AppState) {
|
|||
}
|
||||
|
||||
fn apply_current(state: &mut AppState) {
|
||||
state.clear_burst();
|
||||
state.pending_apply = true;
|
||||
state.set_status("Applying...", StatusLevel::Info);
|
||||
state.dirty = false;
|
||||
// Don't clear `dirty` here: it must survive until the apply actually
|
||||
// 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) {
|
||||
|
|
@ -432,7 +440,6 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
|||
);
|
||||
|
||||
let form_area = chunks[1];
|
||||
let row_height = 1u16;
|
||||
let fields = ConfigField::ALL;
|
||||
|
||||
let items: Vec<ListItem> = fields
|
||||
|
|
@ -454,7 +461,6 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
|||
})
|
||||
.collect();
|
||||
|
||||
let _ = row_height; // used implicitly via ListItem heights
|
||||
let list = List::new(items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
|
|
|
|||
|
|
@ -23,35 +23,48 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
|||
|
||||
match event.code {
|
||||
KeyCode::Char('h') | KeyCode::Left => {
|
||||
state.push_undo();
|
||||
state.micro_edit();
|
||||
move_selected(&state.layout, &mut state.monitors, -step, 0);
|
||||
state.mark_dirty();
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Right => {
|
||||
state.push_undo();
|
||||
state.micro_edit();
|
||||
move_selected(&state.layout, &mut state.monitors, step, 0);
|
||||
state.mark_dirty();
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
state.push_undo();
|
||||
state.micro_edit();
|
||||
move_selected(&state.layout, &mut state.monitors, 0, -step);
|
||||
state.mark_dirty();
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
state.push_undo();
|
||||
state.micro_edit();
|
||||
move_selected(&state.layout, &mut state.monitors, 0, step);
|
||||
state.mark_dirty();
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count),
|
||||
KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(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::Tab | KeyCode::Char('n') => {
|
||||
state.clear_burst();
|
||||
state.layout.next(count);
|
||||
}
|
||||
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') => {
|
||||
state.push_undo();
|
||||
auto_arrange(&mut state.monitors);
|
||||
state.mark_dirty();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
state.clear_burst();
|
||||
state
|
||||
.config
|
||||
.sync_from_monitor(state.layout.selected, &state.monitors);
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ pub struct AppState {
|
|||
pub active_profile: Option<String>,
|
||||
/// Snapshots for Ctrl+Z undo (up to 20 deep).
|
||||
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 {
|
||||
|
|
@ -134,6 +137,7 @@ impl AppState {
|
|||
pending_apply: false,
|
||||
active_profile: None,
|
||||
undo_stack: Vec::new(),
|
||||
undo_in_burst: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -162,6 +166,7 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub fn switch_tab(&mut self, tab: Tab) {
|
||||
self.undo_in_burst = false;
|
||||
self.tab = tab;
|
||||
if tab == Tab::Config {
|
||||
self.config
|
||||
|
|
@ -169,8 +174,32 @@ impl AppState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Save a monitor snapshot for undo (max 20 entries).
|
||||
/// Save a monitor snapshot for undo (max 20 entries) and end any
|
||||
/// in-progress edit burst.
|
||||
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());
|
||||
if self.undo_stack.len() > 20 {
|
||||
self.undo_stack.remove(0);
|
||||
|
|
@ -181,6 +210,7 @@ impl AppState {
|
|||
pub fn undo(&mut self) {
|
||||
if let Some(snapshot) = self.undo_stack.pop() {
|
||||
self.monitors = snapshot;
|
||||
self.undo_in_burst = false;
|
||||
self.mark_dirty();
|
||||
self.layout.clamp_selected(self.monitors.len());
|
||||
// Re-sync config view to the restored state
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue