breadmon/src/main.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

221 lines
7.4 KiB
Rust

mod bread_events;
mod layout;
mod mirror;
mod monitor;
mod profile;
mod store;
mod ui;
use std::io;
use anyhow::Result;
use crossterm::{
event::{
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::StreamExt;
use ratatui::{backend::CrosstermBackend, Terminal};
use tokio::{
sync::mpsc,
time::{interval, Duration},
};
use ui::{AppState, StatusLevel};
#[derive(Debug)]
enum AppEvent {
Key(crossterm::event::KeyEvent),
Mouse(crossterm::event::MouseEvent),
Resize(u16, u16),
Tick,
MonitorChange,
}
#[tokio::main]
async fn main() -> Result<()> {
let mut monitors = monitor::load_monitors().await.unwrap_or_else(|e| {
eprintln!("Warning: could not load monitors: {}", e);
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
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run(&mut terminal, monitors).await;
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
result
}
async fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
monitors: Vec<monitor::Monitor>,
) -> Result<()> {
let size = terminal.size()?;
let mut state = AppState::new(monitors, (size.width, size.height));
let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
// Event reader task
let tx_events = tx.clone();
tokio::spawn(async move {
let mut stream = EventStream::new();
let mut tick = interval(Duration::from_secs(5));
loop {
tokio::select! {
_ = tick.tick() => {
let _ = tx_events.send(AppEvent::Tick);
}
maybe_event = stream.next() => {
match maybe_event {
Some(Ok(Event::Key(key))) => {
let _ = tx_events.send(AppEvent::Key(key));
}
Some(Ok(Event::Mouse(mouse))) => {
let _ = tx_events.send(AppEvent::Mouse(mouse));
}
Some(Ok(Event::Resize(w, h))) => {
let _ = tx_events.send(AppEvent::Resize(w, h));
}
None => break,
_ => {}
}
}
}
}
});
// Hyprland socket hotplug listener
let tx_hotplug = tx.clone();
tokio::spawn(async move {
if let Some(sig) = bread_utils::hypr::socket_path(bread_utils::hypr::Socket::Events) {
if let Ok(mut stream) = tokio::net::UnixStream::connect(&sig).await {
use tokio::io::AsyncBufReadExt;
let reader = tokio::io::BufReader::new(&mut stream);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.starts_with("monitoradded")
|| line.starts_with("monitorremoved")
|| line.starts_with("monitorscale")
{
let _ = tx_hotplug.send(AppEvent::MonitorChange);
}
}
}
}
});
loop {
state.tick_status();
terminal.draw(|f| ui::render(f, &state))?;
let event = rx.recv().await;
match event {
None => break,
Some(AppEvent::Tick) => {
if let Ok(monitors) = monitor::load_monitors().await {
if !state.dirty {
state.monitors = monitors;
state.layout.clamp_selected(state.monitors.len());
}
}
}
Some(AppEvent::MonitorChange) => {
if let Ok(monitors) = monitor::load_monitors().await {
state.monitors = monitors;
state.layout.clamp_selected(state.monitors.len());
state.active_profile = None;
state.set_status("Monitor configuration changed.", StatusLevel::Info);
}
}
Some(AppEvent::Resize(w, h)) => {
state.terminal_size = (w, h);
}
Some(AppEvent::Mouse(mouse)) => {
if mouse.kind == MouseEventKind::Moved {
continue;
}
ui::handle_mouse(mouse, &mut state);
}
Some(AppEvent::Key(key)) => {
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
crossterm::event::KeyCode::Char('a') => {
state.pending_apply = true;
state.set_status("Applying...", StatusLevel::Info);
}
crossterm::event::KeyCode::Char('s') => {
ui::layout_view::trigger_save(&mut state);
}
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);
}
Err(e) => {
state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error);
}
},
_ => {
if !ui::handle_key(key, &mut state) {
break;
}
}
}
}
}
if state.pending_apply {
state.pending_apply = false;
match monitor::apply_monitors(&state.monitors).await {
Ok(()) => {
bread_events::emit_applied(state.active_profile.as_deref());
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) => {
state.set_status(format!("Apply failed: {}", e), StatusLevel::Error);
}
}
}
}
Ok(())
}