Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 43 additions & 45 deletions README.md

Large diffs are not rendered by default.

27 changes: 25 additions & 2 deletions configurator/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
# Wayscriber Configurator (GTK4)

Native Rust desktop UI for editing `~/.config/wayscriber/config.toml`. The application is built on GTK4 and libadwaita through [Relm4](https://relm4.org) and reuses the `wayscriber::Config` types directly, so validation and defaults match the CLI. It also retains the original TOML document so comments, ordering, and settings unknown to this build survive a save.
The configurator is a native Rust desktop UI for editing `~/.config/wayscriber/config.toml`.
It uses GTK4, libadwaita, and [Relm4](https://relm4.org).
It shares the `wayscriber::Config` types with the CLI, so validation and defaults match.
It preserves TOML comments, ordering, and settings unknown to this build when it saves.

`config.toml` changes only through an explicit user edit action, never automatically. This program writes it when you press **Save**; the overlay writes it from three narrow editors — shortcut editing, preset slots, and the quick-color palette — each of which rewrites only its own key and backs the file up first. Nothing else in Wayscriber — daemon, tray, startup, shutdown, validation — ever changes it, so an incidental preference toggle applies to that run and sends you here for a durable change.
`config.toml` changes only when you explicitly edit it. The configurator writes the file when you press **Save**.
The overlay can also save shortcut edits, preset slots, and quick colors.
Each overlay editor changes only its own key and backs up the file first.
The daemon, tray, startup, shutdown, and validation do not change the file.
Other preference changes apply to the current run. Use the configurator to change their defaults.

This file covers building and running the configurator from source. For screenshots, a demo video, and the user-facing walkthrough, see [Configurator (GUI)](../README.md#configurator-gui) and https://wayscriber.com/docs/configuration/configurator.html

Expand Down Expand Up @@ -81,3 +88,19 @@ cargo build --release
```

Artifacts land in `target/release/`. No Node toolchain or bundler is required.

## Workflow ownership

Each workflow module owns a related set of operations:

- `app/document_workflow.rs` prevents loads and saves from running at the same time. It passes the loaded document to the save operation.
- `app/migration_workflow.rs` tracks update offers and dismissals for each document destination.
- `app/shortcut_workflow.rs` keeps shortcut recording, text editing, and conflict resolution separate. Only one can be active at a time.
- `app/daemon_workflow.rs` manages background setup actions, status request identities, and typed feedback.

App update handlers coordinate draft changes and UI effects.

Saves use `Config::validate_for_save` from the core crate.
It compares persisted typed values to detect changes outside keybindings and rejects those changes.
It also returns keybinding validation reports for user feedback.
Save decisions do not depend on diagnostic text.
8 changes: 4 additions & 4 deletions configurator/src/app/component/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ pub(super) fn refresh(app: &ConfiguratorApp, widgets: &mut AppWidgets) {
// draft, so Save is not offered while one is on screen: pressing it
// would write the last value that parsed and lose the text being typed.
let save_enabled = app.is_dirty
&& !app.is_saving
&& !app.is_loading
&& !app.document.is_saving()
&& !app.document.is_loading()
&& app.invalid_color_hex_count() == 0
&& app.pending_shortcut_conflict.is_none();
&& app.shortcuts.conflict().is_none();
if widgets.save_button.is_sensitive() != save_enabled {
widgets.save_button.set_sensitive(save_enabled);
}
let busy = app.is_loading || app.is_saving;
let busy = app.document.is_loading() || app.document.is_saving();
if widgets.reload_button.is_sensitive() == busy {
widgets.reload_button.set_sensitive(!busy);
}
Expand Down
217 changes: 217 additions & 0 deletions configurator/src/app/daemon_workflow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
//! Background setup workflow; callback identity and feedback policy live together.
use super::effects::Effect;
use crate::models::{DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DesktopEnvironment};

#[derive(Debug)]
pub(crate) enum DaemonFeedback {
Status(String),
Action(String),
}
impl DaemonFeedback {
pub(crate) fn text(&self) -> &str {
match self {
Self::Status(text) | Self::Action(text) => text,
}
}
}

#[derive(Debug)]
pub(crate) struct DaemonWorkflow {
pub(crate) status: Option<DaemonRuntimeStatus>,
pub(crate) shortcut_input: String,
pub(crate) feedback: Option<DaemonFeedback>,
active_action: Option<DaemonAction>,
pub(crate) next_status_request_id: u64,
pub(crate) latest_status_request_id: u64,
pub(crate) preserve_feedback_status_request_id: Option<u64>,
}
impl DaemonWorkflow {
pub(crate) fn is_busy(&self) -> bool {
self.active_action.is_some()
}
pub(crate) fn new(desktop: DesktopEnvironment) -> Self {
Self {
status: None,
shortcut_input: desktop.default_shortcut_input().to_string(),
feedback: Some(DaemonFeedback::Status(
"Detecting background mode setup status...".to_string(),
)),
active_action: None,
next_status_request_id: 2,
latest_status_request_id: 1,
preserve_feedback_status_request_id: None,
}
}
}

impl DaemonWorkflow {
pub(crate) fn handle_daemon_status_loaded(
&mut self,
request_id: u64,
result: Result<DaemonRuntimeStatus, String>,
) -> Vec<Effect> {
if request_id != self.latest_status_request_id {
return Vec::new();
}
let preserve_feedback = self.preserve_feedback_status_request_id == Some(request_id);
if preserve_feedback {
self.preserve_feedback_status_request_id = None;
}
match result {
Ok(status) => {
self.apply_daemon_status(status);
if should_update_feedback_after_status_load(
preserve_feedback,
self.is_busy(),
self.feedback.as_ref(),
) {
self.feedback = Some(DaemonFeedback::Status(
"Background mode status loaded.".to_string(),
));
}
}
Err(err) => {
if preserve_feedback && !self.is_busy() {
let previous_feedback = self
.feedback
.as_ref()
.map(DaemonFeedback::text)
.unwrap_or("Background setup action failed.");
self.feedback = Some(DaemonFeedback::Action(format!(
"{previous_feedback}\nStatus refresh failed: {err}"
)));
} else if !self.is_busy() {
self.feedback = Some(DaemonFeedback::Status(format!(
"Failed to load background setup status: {err}"
)));
}
}
}
Vec::new()
}

pub(crate) fn handle_daemon_shortcut_input_changed(&mut self, value: String) -> Vec<Effect> {
self.shortcut_input = value;
Vec::new()
}

pub(crate) fn handle_daemon_action_requested(&mut self, action: DaemonAction) -> Vec<Effect> {
if self.is_busy() {
return Vec::new();
}
self.invalidate_pending_daemon_status_requests();
self.active_action = Some(action);
self.feedback = Some(DaemonFeedback::Action(action_pending_message(action)));
let shortcut_input = self.shortcut_input.clone();
vec![Effect::PerformDaemonAction {
action,
shortcut_input,
}]
}

pub(crate) fn handle_daemon_action_completed(
&mut self,
result: Result<DaemonActionResult, String>,
) -> Vec<Effect> {
self.active_action = None;
match result {
Ok(output) => {
self.apply_daemon_status(output.status);
self.feedback = Some(DaemonFeedback::Action(output.message));
Vec::new()
}
Err(err) => {
self.feedback = Some(DaemonFeedback::Action(format!(
"Background setup action failed: {err}"
)));
self.schedule_daemon_status_reload(true)
}
}
}

fn apply_daemon_status(&mut self, status: DaemonRuntimeStatus) {
if let Some(configured_shortcut) = status.configured_shortcut.clone() {
self.shortcut_input = configured_shortcut;
} else if self.shortcut_input.trim().is_empty() {
self.shortcut_input = status.desktop.default_shortcut_input().to_string();
}
self.status = Some(status);
}

fn schedule_daemon_status_reload(&mut self, preserve_feedback: bool) -> Vec<Effect> {
let request_id = self.next_status_request_id;
self.next_status_request_id = self.next_status_request_id.saturating_add(1);
self.latest_status_request_id = request_id;
if preserve_feedback {
self.preserve_feedback_status_request_id = Some(request_id);
}
vec![Effect::LoadDaemonStatus { request_id }]
}

fn invalidate_pending_daemon_status_requests(&mut self) {
let invalidation_id = self.next_status_request_id;
self.next_status_request_id = self.next_status_request_id.saturating_add(1);
self.latest_status_request_id = invalidation_id;
self.preserve_feedback_status_request_id = None;
}
}

fn should_update_feedback_after_status_load(
preserve_feedback: bool,
busy: bool,
feedback: Option<&DaemonFeedback>,
) -> bool {
!preserve_feedback && !busy && matches!(feedback, None | Some(DaemonFeedback::Status(_)))
}

fn action_pending_message(action: DaemonAction) -> String {
match action {
DaemonAction::RefreshStatus => "Refreshing background setup status...".to_string(),
DaemonAction::InstallOrUpdateService => {
"Installing/updating background service...".to_string()
}
DaemonAction::EnableAndStartService => {
"Enabling and starting background mode...".to_string()
}
DaemonAction::RestartService => "Restarting background service...".to_string(),
DaemonAction::StopAndDisableService => {
"Stopping and disabling background mode...".to_string()
}
DaemonAction::ApplyShortcut => "Applying desktop shortcut setup...".to_string(),
DaemonAction::ApplyLightControls => {
"Applying light passthrough controls setup...".to_string()
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn refresh_policy_depends_on_feedback_kind_not_its_wording() {
let translated_status = DaemonFeedback::Status("Status geladen".into());
assert!(should_update_feedback_after_status_load(
false,
false,
Some(&translated_status)
));
let action_using_status_words =
DaemonFeedback::Action("Background mode status loaded.".into());
assert!(!should_update_feedback_after_status_load(
false,
false,
Some(&action_using_status_words)
));
assert!(!should_update_feedback_after_status_load(
true,
false,
Some(&translated_status)
));
assert!(!should_update_feedback_after_status_load(
false,
true,
Some(&translated_status)
));
}
}
78 changes: 78 additions & 0 deletions configurator/src/app/document_workflow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//! Owns document transfer to effects and mutually exclusive load/save phases.
use std::path::PathBuf;
use wayscriber::config::{ConfigDocument, ConfigValidationReport};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DocumentPhase {
Idle,
Loading,
Saving,
}

#[derive(Debug)]
pub(crate) struct DocumentWorkflow {
loaded: Option<ConfigDocument>,
phase: DocumentPhase,
pub(crate) pending_validation: ConfigValidationReport,
pub(crate) last_backup_path: Option<PathBuf>,
}
impl DocumentWorkflow {
pub(crate) fn loading() -> Self {
Self {
loaded: None,
phase: DocumentPhase::Loading,
pending_validation: Default::default(),
last_backup_path: None,
}
}
pub(crate) fn loaded(&self) -> Option<&ConfigDocument> {
self.loaded.as_ref()
}
pub(crate) fn is_loading(&self) -> bool {
self.phase == DocumentPhase::Loading
}
pub(crate) fn is_saving(&self) -> bool {
self.phase == DocumentPhase::Saving
}
pub(crate) fn begin_reload(&mut self) -> bool {
if self.phase != DocumentPhase::Idle {
return false;
}
self.phase = DocumentPhase::Loading;
true
}
pub(crate) fn finish_load(&mut self, document: Option<ConfigDocument>) {
self.phase = DocumentPhase::Idle;
if document.is_some() {
self.loaded = document;
}
}
pub(crate) fn begin_save(&mut self) -> Option<ConfigDocument> {
if self.phase != DocumentPhase::Idle {
return None;
}
let document = self.loaded.take()?;
self.phase = DocumentPhase::Saving;
Some(document)
}
pub(crate) fn finish_save(&mut self, document: Option<ConfigDocument>) {
self.loaded = document;
self.phase = DocumentPhase::Idle;
}
#[cfg(test)]
pub(crate) fn set_loading_for_test(&mut self, loading: bool) {
self.phase = if loading {
DocumentPhase::Loading
} else {
DocumentPhase::Idle
};
}
#[cfg(test)]
pub(crate) fn set_saving_for_test(&mut self, saving: bool) {
self.phase = if saving {
DocumentPhase::Saving
} else {
DocumentPhase::Idle
};
}
}
Loading
Loading