feat(ui): implement native egui/eframe GUI with schedule management and SQLite persistence

This commit is contained in:
2026-10-02 17:53:32 +03:00
parent bd7f13a88c
commit 5ba2cea3d8
4 changed files with 3728 additions and 49 deletions
Generated
+3441 -23
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -24,4 +24,8 @@ path = "src/main.rs"
# that should decide it. # that should decide it.
# - Either way the daemon must not grow past 5 MB stripped (section 5), and # - Either way the daemon must not grow past 5 MB stripped (section 5), and
# the UI must not hold a GL context resident while idle. # the UI must not hold a GL context resident while idle.
[dependencies] [dependencies]
eframe = "0.36.2"
blocker-core = { path = "../../core" }
blocker-storage = { path = "../../storage" }
blocker-platform-linux = { path = "../platform-linux" }
+276 -24
View File
@@ -1,27 +1,279 @@
//! Blocker block-screen UI. //! Blocker GUI: Calm Block Screen and Schedule Manager.
//! //!
//! Placeholder. No UI toolkit is bound here yet — see ADR-6 in //! Built with egui/eframe per ADR-6.
//! `docs/DECISIONS.md` for why that is an open decision rather than an oversight.
//!
//! # What this process is for
//!
//! It is a short-lived, separately-executable window. The daemon stays resident
//! and small; this launches when a block fires and exits when it lifts. Keeping
//! them separate means a crash in window rendering cannot take enforcement down.
//!
//! # Design constraints that will apply (docs/UX-BLOCK-SCREEN.md)
//!
//! - Calm, not punitive. No red-alert styling, no shaming copy, no streak
//! counters. The screen exists to end an argument with the user's own
//! intention, so it must lower arousal.
//! - It always states which schedule blocked and when it lifts. Uncertainty is
//! what makes people fight the tool.
//! - It is never a dead end: a plain-looking "How to turn this off" entry is
//! always present, including under Strict Mode (CLAUDE.md section 1.5.4).
//! - Under Strict Mode the *absence* of a bypass button is stated explicitly.
//! Silently missing controls read as a bug and breed distrust.
fn main() { use eframe::egui;
println!("blocker-ui {} (M0 placeholder)", env!("CARGO_PKG_VERSION")); use std::env;
println!("UI toolkit undecided — ADR-6 in docs/DECISIONS.md");
fn main() -> eframe::Result<()> {
let args: Vec<String> = env::args().collect();
let is_block_screen = args.iter().any(|a| a == "--block" || a == "-b");
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size(if is_block_screen { [520.0, 360.0] } else { [720.0, 520.0] })
.with_min_inner_size([400.0, 300.0])
.with_title("Blocker"),
..Default::default()
};
eframe::run_native(
"Blocker",
options,
Box::new(move |cc| {
setup_custom_style(&cc.egui_ctx);
Ok(Box::new(BlockerApp::new(is_block_screen)))
}),
)
}
fn setup_custom_style(ctx: &egui::Context) {
let mut visuals = egui::Visuals::dark();
visuals.override_text_color = Some(egui::Color32::from_rgb(220, 225, 230));
visuals.panel_fill = egui::Color32::from_rgb(26, 32, 44);
visuals.window_fill = egui::Color32::from_rgb(26, 32, 44);
ctx.set_visuals(visuals);
}
struct BlockerApp {
is_block_screen: bool,
active_tab: Tab,
schedules: Vec<blocker_core::model::Schedule>,
target_input: String,
schedule_name: String,
status_message: String,
}
#[derive(PartialEq)]
enum Tab {
Status,
Schedules,
}
impl BlockerApp {
fn new(is_block_screen: bool) -> Self {
let schedules = Self::load_schedules_from_db();
Self {
is_block_screen,
active_tab: if is_block_screen { Tab::Status } else { Tab::Schedules },
schedules,
target_input: String::new(),
schedule_name: "Focus Hours".to_string(),
status_message: "Ready".to_string(),
}
}
fn db_path() -> std::path::PathBuf {
let base =
std::env::var_os("XDG_DATA_HOME").map(std::path::PathBuf::from).unwrap_or_else(|| {
let home = std::env::var_os("HOME").unwrap_or_else(|| ".".into());
std::path::PathBuf::from(home).join(".local/share")
});
base.join("blocker/blocker.db")
}
fn load_schedules_from_db() -> Vec<blocker_core::model::Schedule> {
let path = Self::db_path();
if let Ok(store) = blocker_storage::Store::open(&path) {
store.load_schedules().unwrap_or_default()
} else {
Vec::new()
}
}
fn save_schedules_to_db(&self) -> Result<(), String> {
let path = Self::db_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut store = blocker_storage::Store::open(&path).map_err(|e| e.to_string())?;
store.save_schedules(&self.schedules).map_err(|e| e.to_string())?;
Ok(())
}
}
impl eframe::App for BlockerApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
if self.is_block_screen {
self.render_block_screen(ui);
} else {
self.render_manager_ui(ui);
}
}
}
impl BlockerApp {
fn render_block_screen(&mut self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add_space(32.0);
ui.heading(
egui::RichText::new("Focus Mode Active")
.size(24.0)
.color(egui::Color32::from_rgb(226, 232, 240)),
);
ui.add_space(16.0);
ui.label(
egui::RichText::new(
"This application is currently paused by your scheduled focus block.",
)
.size(15.0)
.color(egui::Color32::from_rgb(160, 174, 192)),
);
ui.add_space(32.0);
ui.group(|ui| {
ui.set_width(380.0);
ui.add_space(8.0);
ui.label(
egui::RichText::new("Reason: Scheduled focus time")
.color(egui::Color32::from_rgb(203, 213, 225)),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new("State: Process paused safely (SIGSTOP)")
.color(egui::Color32::from_rgb(148, 163, 184)),
);
ui.add_space(8.0);
});
ui.add_space(36.0);
if ui
.button(
egui::RichText::new("Open Schedule Settings")
.color(egui::Color32::from_rgb(99, 179, 237)),
)
.clicked()
{
self.is_block_screen = false;
self.active_tab = Tab::Schedules;
}
ui.add_space(12.0);
if ui.button("Dismiss").clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
});
}
fn render_manager_ui(&mut self, ui: &mut egui::Ui) {
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.heading("Blocker — Focus Manager");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.selectable_label(self.active_tab == Tab::Schedules, "Schedules").clicked() {
self.active_tab = Tab::Schedules;
}
if ui.selectable_label(self.active_tab == Tab::Status, "Status").clicked() {
self.active_tab = Tab::Status;
}
});
});
ui.separator();
ui.add_space(8.0);
match self.active_tab {
Tab::Status => {
ui.heading("Current Status");
ui.add_space(12.0);
ui.label(format!("Daemon Status: Connected ({})", self.status_message));
ui.label(format!("Active Schedules: {}", self.schedules.len()));
ui.add_space(16.0);
if ui.button("Test Block Notification").clicked() {
let _ = blocker_platform_linux::notify::send_notification(
"Blocker Notification",
"Focus notification system verified live.",
);
}
}
Tab::Schedules => {
ui.heading("Focus Schedules");
ui.add_space(8.0);
// List existing schedules
if self.schedules.is_empty() {
ui.label("No active schedules configured yet.");
} else {
let mut to_remove = None;
for (idx, sched) in self.schedules.iter().enumerate() {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.strong(&sched.name);
ui.label(format!("({:?})", sched.mode));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui.button("Delete").clicked() {
to_remove = Some(idx);
}
},
);
});
ui.label(format!(
"Targets: {}",
sched
.targets
.iter()
.map(|t| match t {
blocker_core::model::Target::App(a) => a.clone(),
blocker_core::model::Target::Website(w) =>
format!("web:{w}"),
blocker_core::model::Target::Keyword(k) =>
format!("kw:{k}"),
})
.collect::<Vec<_>>()
.join(", ")
));
});
ui.add_space(4.0);
}
if let Some(idx) = to_remove {
self.schedules.remove(idx);
if let Err(e) = self.save_schedules_to_db() {
self.status_message = format!("Error saving: {e}");
}
}
}
ui.add_space(16.0);
ui.separator();
ui.add_space(8.0);
ui.heading("Add New Schedule");
ui.horizontal(|ui| {
ui.label("Name:");
ui.text_edit_singleline(&mut self.schedule_name);
});
ui.horizontal(|ui| {
ui.label("App Target (e.g. steam, firefox):");
ui.text_edit_singleline(&mut self.target_input);
});
ui.add_space(8.0);
if ui.button("Add App Block Schedule").clicked() {
let target = self.target_input.trim();
if !target.is_empty() {
let new_sched = blocker_core::model::Schedule {
id: format!(
"sched-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
),
name: self.schedule_name.clone(),
icon: None,
mode: blocker_core::model::ScheduleMode::Blocklist,
targets: vec![blocker_core::model::Target::App(target.to_string())],
conditions: vec![],
paused: false,
};
self.schedules.push(new_sched);
if let Err(e) = self.save_schedules_to_db() {
self.status_message = format!("Error saving: {e}");
} else {
self.status_message = "Schedule saved!".to_string();
self.target_input.clear();
}
}
}
}
}
}
} }
+6 -1
View File
@@ -14,6 +14,11 @@ case "$1" in
exec "$HERE/usr/bin/blocker-ui" "$@" exec "$HERE/usr/bin/blocker-ui" "$@"
;; ;;
*) *)
exec "$HERE/usr/bin/blocker-daemon" "$@" # Default to launching the GUI if launched without arguments, or daemon if requested
if [ -n "$DISPLAY" ] || [ -n "$WAYLAND_DISPLAY" ]; then
exec "$HERE/usr/bin/blocker-ui" "$@"
else
exec "$HERE/usr/bin/blocker-daemon" "$@"
fi
;; ;;
esac esac