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
11 changes: 9 additions & 2 deletions .github/workflows/build-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ jobs:
grep -Fq '/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell' <<< "$deb_files"
configurator_deb_depends="$(dpkg-deb -f dist/wayscriber-configurator-amd64.deb Depends)"
grep -Fq 'libc6 (>= 2.39)' <<< "$configurator_deb_depends"
grep -Fq 'libadwaita-1-0 (>= 1.4)' <<< "$configurator_deb_depends"

mkdir -p "$RUNNER_TEMP/rpmdb"
test "$(rpm --dbpath "$RUNNER_TEMP/rpmdb" -qp --qf '%{VERSION}-%{RELEASE}\n' dist/wayscriber-x86_64.rpm)" = "$expected_package_version"
Expand All @@ -150,6 +151,7 @@ jobs:
grep -Fxq '/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell' <<< "$rpm_files"
configurator_rpm_requires="$(rpm --dbpath "$RUNNER_TEMP/rpmdb" -qp --requires dist/wayscriber-configurator-x86_64.rpm)"
grep -Fxq 'glibc >= 2.39' <<< "$configurator_rpm_requires"
grep -Fxq 'libadwaita >= 1.4' <<< "$configurator_rpm_requires"

tar_files="$(tar -tzf "dist/wayscriber-v${{ steps.meta.outputs.version }}-linux-x86_64.tar.gz")"
grep -Eq '/usr/bin/wayscriber$' <<< "$tar_files"
Expand All @@ -164,8 +166,13 @@ jobs:
ubuntu:24.04 \
bash -euxo pipefail -c '
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y /dist/wayscriber-amd64.deb
DEBIAN_FRONTEND=noninteractive apt-get install -y \
/dist/wayscriber-amd64.deb \
/dist/wayscriber-configurator-amd64.deb
wayscriber --version
# Installing proves the configurator runtime dependencies resolve
# on the supported LTS. Do not launch this foreground GUI here.
test -x /usr/bin/wayscriber-configurator
'

- name: Upload tarball
Expand Down Expand Up @@ -413,7 +420,7 @@ jobs:
run: |
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber.git aur-wayscriber
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber-bin.git aur-wayscriber-bin
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber-configurator.git aur-wayscriber-configurator || true
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=yes" git clone ssh://aur@aur.archlinux.org/wayscriber-configurator.git aur-wayscriber-configurator

- name: Update AUR from manifest
env:
Expand Down
2 changes: 1 addition & 1 deletion configurator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ if its UI task is no longer observed.

- **Reload** – re-read `config.toml` from disk and refresh the guarded source revision. A transient load error leaves the last good document and current draft in place.
- **Configuration update available** – shown when the file's `config_revision` predates this build's keybinding defaults. The banner lists every proposed shortcut change as before → after; **Apply Update** edits the draft only, and **Dismiss** hides the offer for this run. Nothing reaches disk until you Save, and saving an unrelated setting without applying leaves both the old bindings and the old revision on disk.
- **Defaults** – drop in the built-in defaults without saving.
- **Defaults** – drop in the built-in defaults without saving. Pressing it asks first: **Confirm Defaults** replaces the draft and **Cancel** withdraws the question, and editing anything withdraws it too. Pressing **Defaults** again changes nothing.
- **Save** – validate inputs (including numeric ranges and color arrays), merge known changes into the source TOML, and write it atomically. An existing file is backed up with a timestamp. Save is refused if the file was created, deleted, retargeted through a symlink, or changed byte-for-byte after loading; reload before retrying. If a readable file cannot be parsed, the configurator offers a warning-marked defaults-based repair draft and backs up the unreadable source before saving it. Unknown settings are retained only when the TOML structure is parseable and safely separable; malformed content remains in the backup.
- **Search** – filter tabs, sections, saved sessions, boards, render profiles, presets, and keybindings as you type. Press `Ctrl+F` to focus search and `Escape` to clear it.
- Launch from the main overlay with the default `F11` keybinding (configurable inside the app).
Expand Down
68 changes: 60 additions & 8 deletions configurator/src/app/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub(crate) struct AppWidgets {
migration_seen: String,
save_button: gtk::Button,
defaults_button: gtk::Button,
defaults_confirm_button: gtk::Button,
defaults_cancel_button: gtk::Button,
reload_button: gtk::Button,
sidebar_rows: Vec<(TabId, gtk::ListBoxRow)>,
sidebar: gtk::ListBox,
Expand Down Expand Up @@ -165,13 +167,45 @@ impl Component for ConfiguratorApp {
let sender = sender.clone();
reload_button.connect_clicked(move |_| sender.input(Message::ReloadRequested));
}
// Asking for the reset and answering for it are different messages,
// so they are different controls: while the confirmation stands, the
// button that asks steps aside for the pair that answers. All three
// exist from the start and visibility picks between them, which is
// what keeps a repeat of the same press from ever applying defaults.
let defaults_button = gtk::Button::with_label("Defaults");
{
let sender = sender.clone();
defaults_button.connect_clicked(move |_| {
sender.input(Message::ResetToDefaultsRequested);
});
}
let defaults_confirm_button = gtk::Button::builder()
.label("Confirm Defaults")
.visible(false)
.css_classes(["destructive-action"])
.build();
{
let sender = sender.clone();
defaults_confirm_button.connect_clicked(move |_| {
sender.input(Message::ResetToDefaultsConfirmed);
});
}
let defaults_cancel_button = gtk::Button::builder()
.label("Cancel")
.visible(false)
.css_classes(["flat"])
.build();
{
let sender = sender.clone();
defaults_cancel_button.connect_clicked(move |_| {
sender.input(Message::ResetToDefaultsCanceled);
});
}
let defaults_box = gtk::Box::new(gtk::Orientation::Horizontal, 6);
defaults_box.append(&defaults_button);
defaults_box.append(&defaults_confirm_button);
defaults_box.append(&defaults_cancel_button);

let save_button = gtk::Button::with_label("Save");
save_button.add_css_class("suggested-action");
{
Expand All @@ -183,7 +217,7 @@ impl Component for ConfiguratorApp {
.title_widget(&window_title)
.build();
header.pack_start(&reload_button);
header.pack_start(&defaults_button);
header.pack_start(&defaults_box);
header.pack_end(&save_button);

// ---- Status + migration strip -----------------------------------
Expand Down Expand Up @@ -320,6 +354,8 @@ impl Component for ConfiguratorApp {
migration_seen: String::new(),
save_button,
defaults_button,
defaults_confirm_button,
defaults_cancel_button,
reload_button,
sidebar_rows,
sidebar,
Expand Down Expand Up @@ -369,13 +405,18 @@ impl Component for ConfiguratorApp {
if widgets.reload_button.is_sensitive() == busy {
widgets.reload_button.set_sensitive(!busy);
}
let defaults_label = if self.defaults_reset_pending {
"Confirm reset?"
} else {
"Defaults"
};
if widgets.defaults_button.label().as_deref() != Some(defaults_label) {
widgets.defaults_button.set_label(defaults_label);
// The armed confirmation is the model's, so which of the two Defaults
// affordances is on screen follows it: asking is offered until the
// question stands, answering only while it does.
let defaults_armed = self.defaults_reset_pending;
let defaults_arming = defaults_armed && !widgets.defaults_confirm_button.get_visible();
set_visible(&widgets.defaults_button, !defaults_armed);
set_visible(&widgets.defaults_confirm_button, defaults_armed);
set_visible(&widgets.defaults_cancel_button, defaults_armed);
if defaults_arming {
// The Defaults button just stepped aside. Keep keyboard users in
// the revealed flow instead of leaving focus on a hidden widget.
widgets.defaults_confirm_button.grab_focus();
}

// Status strip.
Expand All @@ -385,6 +426,7 @@ impl Component for ConfiguratorApp {
StatusMessage::Success(text) => (text.as_str(), Some("success")),
StatusMessage::Warning(text) => (text.as_str(), Some("warning")),
StatusMessage::Error(text) => (text.as_str(), Some("error")),
StatusMessage::Confirmation(prompt) => (prompt.message(), Some("warning")),
};
if widgets.status_label.text() != status_text {
widgets.status_label.set_text(status_text);
Expand Down Expand Up @@ -448,6 +490,16 @@ impl Component for ConfiguratorApp {
}
}

/// Writes the widget's own visibility flag, never `is_visible`: a widget
/// inside a hidden parent reports invisible while its own flag still says
/// otherwise, and skipping the write there would leak the stale state the
/// moment the parent comes back.
fn set_visible(widget: &impl IsA<gtk::Widget>, visible: bool) {
if widget.get_visible() != visible {
widget.set_visible(visible);
}
}

/// Runs one effect as a Relm4 command; its result re-enters the component
/// as an ordinary message through `update_cmd`.
fn spawn_effect(effect: Effect, sender: &ComponentSender<ConfiguratorApp>) {
Expand Down
44 changes: 43 additions & 1 deletion configurator/src/app/pages/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,11 @@ fn item_card(item: &CatalogItemLayout, sender: &ComponentSender<ConfiguratorApp>
);
confirm_button.add_css_class("destructive-action");
confirm.append(&confirm_button);
let cancel_button = message_button("Cancel", sender, Message::SessionCatalogClearCanceled);
let cancel_button = message_button(
"Cancel",
sender,
Message::SessionCatalogClearCanceled(item.id.clone()),
);
cancel_button.add_css_class("flat");
confirm.append(&cancel_button);
danger.append(&confirm);
Expand Down Expand Up @@ -540,9 +544,15 @@ fn item_card(item: &CatalogItemLayout, sender: &ComponentSender<ConfiguratorApp>
set_sensitive(&forget, values.actions_enabled);
set_sensitive(&tool_state, values.tool_state_enabled);

let clear_arming = values.clear_armed && !confirm.get_visible();
set_visible(&clear, !values.clear_armed);
set_sensitive(&clear, values.clear_enabled);
set_visible(&confirm, values.clear_armed);
if clear_arming {
// The destructive action just stepped aside. Move keyboard focus
// to the revealed answer rather than leaving it hidden.
confirm_button.grab_focus();
}
});

CatalogRow { card, refresh }
Expand Down Expand Up @@ -831,6 +841,38 @@ mod tests {
assert!(!clear_armed(None, "one"));
}

/// Confirming consumes the pending id as it sets the catalog busy, so the
/// card leaves its armed state in the same refresh that starts the work:
/// the Confirm/Cancel pair goes away and the button that asks comes back
/// unpressable, rather than offering a confirm the model would refuse.
#[test]
fn a_confirmed_clear_collapses_the_armed_row_into_the_busy_one() {
let mut app = app_with_items(vec![test_item("one", "First")]);
app.session_catalog.pending_clear_id = Some("one".to_string());
let summary = app.search_summary();
let armed = catalog_row_values(
&app,
&summary,
&CatalogGates::of(&app),
&app.session_catalog.items[0],
);
assert!(armed.clear_armed);

// What `handle_session_catalog_clear_confirmed` leaves behind: the
// answered question consumed, the clear running.
app.session_catalog.pending_clear_id = None;
app.session_catalog.busy = true;
let running = catalog_row_values(
&app,
&summary,
&CatalogGates::of(&app),
&app.session_catalog.items[0],
);

assert!(!running.clear_armed);
assert!(!running.clear_enabled);
}

#[test]
fn whole_number_validation_matches_the_old_hints() {
assert_eq!(validate_whole_number("1000", 1000, u64::MAX), None);
Expand Down
8 changes: 1 addition & 7 deletions configurator/src/app/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,7 @@ mod tests {
use crate::test_temp::{TempDir, tempdir};

fn status_text(status: &StatusMessage) -> String {
match status {
StatusMessage::Info(text)
| StatusMessage::Success(text)
| StatusMessage::Error(text)
| StatusMessage::Warning(text) => text.clone(),
StatusMessage::Idle => String::new(),
}
status.text().unwrap_or_default().to_string()
}

fn args(values: &[&str]) -> Vec<std::ffi::OsString> {
Expand Down
42 changes: 42 additions & 0 deletions configurator/src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,33 @@ pub(crate) struct ConfiguratorApp {
pub(crate) startup_request: StartupRequest,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConfirmationPrompt {
DefaultsReset,
SessionClear,
}

impl ConfirmationPrompt {
pub(crate) fn message(self) -> &'static str {
match self {
ConfirmationPrompt::DefaultsReset => {
"Defaults will replace the current draft with built-in defaults. Press \"Confirm Defaults\" to continue."
}
ConfirmationPrompt::SessionClear => {
"Clear saved data removes the selected session primary and non-lock sidecars. Press Confirm Clear to continue."
}
}
}
}

#[derive(Debug, Clone)]
pub(crate) enum StatusMessage {
Idle,
Info(String),
Success(String),
Error(String),
Warning(String),
Confirmation(ConfirmationPrompt),
}

impl StatusMessage {
Expand All @@ -101,6 +121,25 @@ impl StatusMessage {
StatusMessage::Warning(message.into())
}

pub(crate) fn confirmation(prompt: ConfirmationPrompt) -> Self {
StatusMessage::Confirmation(prompt)
}

pub(crate) fn is_confirmation(&self, prompt: ConfirmationPrompt) -> bool {
matches!(self, StatusMessage::Confirmation(current) if *current == prompt)
}

pub(crate) fn text(&self) -> Option<&str> {
match self {
StatusMessage::Idle => None,
StatusMessage::Info(text)
| StatusMessage::Success(text)
| StatusMessage::Error(text)
| StatusMessage::Warning(text) => Some(text.as_str()),
StatusMessage::Confirmation(prompt) => Some(prompt.message()),
}
}

/// Adds a sentence without discarding what is already there.
///
/// The load status can be carrying this file's diagnostics, and a note
Expand All @@ -111,6 +150,9 @@ impl StatusMessage {
StatusMessage::Info(text)
| StatusMessage::Success(text)
| StatusMessage::Warning(text) => StatusMessage::warning(format!("{text}\n{note}")),
StatusMessage::Confirmation(prompt) => {
StatusMessage::warning(format!("{}\n{note}", prompt.message()))
}
// A failed load is the more urgent of the two; keep its styling.
StatusMessage::Error(text) => StatusMessage::error(format!("{text}\n{note}")),
}
Expand Down
Loading
Loading