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
8 changes: 4 additions & 4 deletions docs/adr/003-settings-single-page-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Accepted
## Context

The settings GUI (`hyprlay-gui`, `src/gui/`) shows one section at a time.
The sidebar sends `Message::Section(Section)` (`src/gui/mod.rs`, `sidebar`),
The sidebar sends `Message::Section(Section)` (`src/gui/view.rs`, `sidebar`),
and `section_page(gui, section)` (`src/gui/fields.rs`) renders that section
alone inside its own scrollable. Moving between sections means a full page
swap, so the user cannot see Position and Layout together or compare values
Expand All @@ -32,7 +32,7 @@ sources for the needed mechanics:
- `iced_runtime::task::widget(operation)` runs a custom operation and
delivers its `finish()` output as a message. The repo already uses a
runtime operation this way (`iced_runtime::widget::operation::focus` in
`src/gui/mod.rs`).
`src/gui/update.rs`).

The repo uses this same widget-id mechanism for the search box
(`SEARCH_ID` in `src/gui/fields.rs`).
Expand Down Expand Up @@ -108,12 +108,12 @@ fires exactly when the offset changes, so polling is strictly more work.
end. `Navigate → Measured → scroll_to` fires `on_scroll` once on landing,
which re-measures and re-derives the same section.
- The sidebar highlight stays suppressed while search text is set; the
existing selected condition in `sidebar` (`src/gui/mod.rs`) keeps its
existing selected condition in `sidebar` (`src/gui/view.rs`) keeps its
`gui.search.trim().is_empty()` guard.
- New state on `Gui`: `section_offsets` and `last_scroll_y`, plus the
`CONTENT_SCROLL_ID` and per-section anchor widget ids. Two pure helpers,
`active_section_for` and `offset_within_content`, carry the math and are
unit-tested in `src/gui/mod.rs`.
unit-tested in `src/gui/scroll.rs`.
- Offsets measured at jump time mean stale measurements cannot send the
viewport to the wrong place, at the cost of one measure operation per
scroll event batch.
6 changes: 5 additions & 1 deletion docs/dev/code-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ boundary.
| `src/daemon/adapters/ipc.rs` | transport-agnostic `IpcStream` + `DiscordTransport` port | Discord's local IPC wire format: 8-byte LE header, handshake, PING/PONG; per-OS discovery + connect (unix socket / named pipe) behind the package-local `DiscordTransport` port (Adapter) |
| `src/daemon/adapters/auth.rs` | `detect() -> Option<OwnAppAuth>`, `exchange(code)` | Credential resolution (env → auth.json) and the OAuth code exchange |
| `src/daemon/adapters/{cache,avatar,token}.rs` | roster/avatar/token stores | On-disk persistence with tracing on real failures |
| `src/gui/mod.rs` | iced app `Gui::run()` | Window layout (header / sidebar / content / status bar), field registry, search; every change becomes a `Command` |
| `src/gui/mod.rs` | iced app shell: `Gui::run()` | `Message`, `Gui`, boot/subscribe wiring, window settings, and the blocking `send` wrapper; every change stays a `Command` — the layer modules below own the rest |
| `src/gui/update.rs` | `pub(super)` `update(gui, msg)` | The one flat update match (the app's dispatch table), the `shortcut` dispatcher, and the async daemon-toggle / auth effects |
| `src/gui/commands.rs` | `pub(super)` `command_for`, `apply_num`, `revert_commands` | Message → Command translation plus the bookkeeping the update arms share: unsaved marker, numeric bounds check, revert diff |
| `src/gui/scroll.rs` | `pub(super)` `measure_sections`, `scroll_to_section` | One-page navigation: the measure operation, section jumps, scrollspy highlight, and the shared widget ids |
| `src/gui/view.rs` | `pub(super)` `view(gui)` | Window composition: header (title, search, global actions), sidebar (section anchors), status bar (unsaved marker, daemon toggle, last reply) |
| `src/gui/fields.rs` | per-key field registry | Section, label, tooltip, and control rendering for each setting |
| `src/gui/daemon.rs` | `DaemonState` machine | Status chip states (connecting… / up / daemon not active) and the Start/Stop toggle plumbing (systemctl vs spawn vs `quit`) |
| `src/gui/picker.rs` | color picker widget | Color selection UI |
Expand Down
218 changes: 218 additions & 0 deletions src/gui/commands.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
//! Message → Command translation and the bookkeeping the update arms
//! share: the unsaved-changes marker, the numeric bounds check, and the
//! revert diff behind "clear changes".

use hyprlay_core::config::Config;
use hyprlay_core::domain::Command;
use hyprlay_core::domain::Key;
use hyprlay_core::domain::Value;
use hyprlay_core::domain::corner_of;
use iced::Task;

use super::Gui;
use super::Message;
use super::send;

/// Commit one numeric value to the mirror and the daemon. The caller has
/// already made sure the value is inside the key's bounds.
pub(super) fn apply_num(gui: &mut Gui, key: Key, value: i64) -> Task<Message> {
gui.num_drafts.remove(&key);
let command = Command::Set(key, Value::Num(value));
mark_dirty(gui, &command);
command.clone().apply_config(&mut gui.config);
Task::perform(send(command.to_string()), Message::Applied)
}

/// Mirror of the daemon's persistence rule: a change is "unsaved" exactly
/// when the daemon would not have persisted it. Decided with the
/// pre-application autosave value — the same one the daemon uses — so
/// flipping auto-save itself never leaves a phantom badge.
pub(super) fn mark_dirty(gui: &mut Gui, command: &Command) {
if !hyprlay_core::domain::should_persist(command, gui.config.auto_save) {
gui.dirty = true;
}
}

pub(super) fn num_in_bounds(key: Key, v: i64) -> bool {
key.num_bounds()
.is_some_and(|(min, max)| v >= min && v <= max)
}

/// Commands that bring `live` back to `saved`, one per differing key.
/// Used by "clear changes"; empty when there is nothing to revert. Walking
/// the shared [`Key`] table means a newly added setting can never be
/// forgotten here — it shows up in the diff the moment it exists.
pub(super) fn revert_commands(live: &Config, saved: &Config) -> Vec<Command> {
Key::ALL
.into_iter()
.filter(|k| k.value_of(live) != k.value_of(saved))
.map(|k| Command::Set(k, k.value_of(saved)))
.collect()
}

/// Turn a GUI interaction into its control-socket command. The local config
/// mirror is the same [`Command::apply_config`] the daemon runs, so both
/// sides can never disagree about what a setting change means.
pub(super) fn command_for(message: Message) -> Command {
match message {
Message::Position(h, v) => Command::Set(Key::Position, Value::Corner(corner_of(h, v))),
// Rides the generic apply path like Position: mirror locally, send
// the same wire command the CLI would.
Message::Anchor(mode) => Command::Set(Key::Anchor, Value::Anchor(mode)),
Message::SetFlag(..) => unreachable!("flags are handled directly in update"),
// Handled directly in `update`; unreachable here.
Message::NumText(..)
| Message::NumDrag(..)
| Message::NumReset(_)
| Message::ColorPart(..)
| Message::ColorHex(..)
| Message::PickerToggle(..)
| Message::SvPress(..)
| Message::SvMove(..)
| Message::HuePress(..)
| Message::HueMove(..)
| Message::PickerRelease
| Message::Palette(_)
| Message::Navigate(_)
| Message::Scrolled(_)
| Message::Measured { .. }
| Message::Search(_)
| Message::KeyPressed(_)
| Message::Save
| Message::ClearChanges
| Message::ResetAll
| Message::ResetSection(_)
| Message::SwitchMonitor(_)
| Message::Monitors(_)
| Message::Applied(_)
| Message::RefreshStatus
| Message::ToggleDaemon
| Message::ToggleResult(_)
| Message::AuthClientId(_)
| Message::AuthClientSecret(_)
| Message::AuthApply => unreachable!("handled before command_for"),
}
}

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

#[test]
fn anchor_setting_roundtrips_through_apply_and_revert() {
// The exact Command path the GUI's generic change pipeline drives.
let mut live = Config::default();
let pin_bottom = Command::Set(
Key::Anchor,
Value::Anchor(hyprlay_core::config::AnchorMode::Bottom),
);
pin_bottom.clone().apply_config(&mut live);
assert_eq!(live.anchor, hyprlay_core::config::AnchorMode::Bottom);

// Reverting mirrors what "clear changes" replays: read the saved
// value back through the shared table and re-apply it.
let saved = Config::default();
let revert = Command::Set(Key::Anchor, Key::Anchor.value_of(&saved));
revert.apply_config(&mut live);
assert_eq!(live.anchor, saved.anchor);
}

#[test]
fn key_sets_use_the_cli_wire_names() {
use hyprlay_core::config::OFFSETS;
assert_eq!(
Command::Set(Key::Opacity, Value::Num(42)).to_string(),
"set opacity 42"
);
assert_eq!(
Command::Set(Key::OffsetX, Value::Num(-12)).to_string(),
"set offset-x -12"
);
assert_eq!(
Command::Set(Key::TalkingOnly, Value::Flag(true)).to_string(),
"set talking-only on"
);
assert!(
(OFFSETS.min as i64..=OFFSETS.max as i64).contains(&-12),
"test value must stay inside the shared bounds"
);
}

#[test]
fn every_numeric_key_has_sane_bounds() {
for key in Key::ALL {
if let Some((min, max)) = key.num_bounds() {
assert!(min <= max, "{} has an inverted range", key.name());
} else {
// Non-numeric keys must not pretend to have slider bounds.
assert!(
!key.slider_bounds(&Config::default()).is_some()
|| matches!(
key,
Key::OffsetX
| Key::OffsetY
| Key::Width
| Key::Scale
| Key::AvatarSize
| Key::TextSize
| Key::Spacing
| Key::MaxName
| Key::Opacity
| Key::AvatarOpacity
| Key::TextOpacity
| Key::BoxOpacity
),
"{} renders a slider without numeric bounds",
key.name()
);
}
}
}

#[test]
fn revert_commands_do_nothing_when_configs_match() {
let cfg = Config::default();
assert!(revert_commands(&cfg, &cfg).is_empty());
}

#[test]
fn revert_commands_cover_every_differing_key_once() {
use hyprlay_core::config::HorizontalAnchor as H;
use hyprlay_core::config::VerticalAnchor as V;
// show_own_user defaults to true, so flipping it off is a real diff.
let saved = Config {
horizontal: H::Right,
vertical: V::Top, // top-right corner
rtl: true,
offset_x: 40,
opacity: 70,
width: 500,
show_own_user: false,
monitor: Some("DP-2".into()),
speaking_color: "#00ff00".parse().unwrap(),
..Config::default()
};

let cmds = revert_commands(&Config::default(), &saved);
for expected in [
"set position top-right",
"set rtl on",
"set offset-x 40",
"set opacity 70",
"set width 500",
"set own-user off",
"set monitor DP-2",
] {
assert!(
cmds.iter().any(|c| c.to_string() == expected),
"missing revert command {expected}"
);
}
assert!(
cmds.iter()
.any(|c| c.to_string().starts_with("set speaking-color "))
);
// Exactly one command per changed key — no redundant spam.
assert_eq!(cmds.len(), 8, "unexpected extra commands: {cmds:?}");
}
}
89 changes: 89 additions & 0 deletions src/gui/fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -795,3 +795,92 @@ fn preset_button<'a>(cfg: &'a Config, h: H, v: V, label: &'a str) -> Element<'a,
.width(Length::Fill)
.into()
}

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

#[test]
fn search_matches_label_tip_and_section_name() {
let field = Field {
section: Section::Position,
label: "offset x",
tip: "Horizontal distance in px from the anchored screen edge.",
render: f_offset_x,
};
assert!(search_matches(&field, "offset"));
assert!(search_matches(&field, "horizontal"));
assert!(search_matches(&field, "POSITION"));
assert!(!search_matches(&field, "avatar"));
assert!(!search_matches(&field, ""));
}

#[test]
fn sections_map_one_to_one_onto_config_groups() {
use hyprlay_core::domain::Group;
// Only config-backed sections participate; Connection has no group.
let config_backed: Vec<_> = Section::ALL
.into_iter()
.filter_map(|section| section.group().map(|group| (section, group)))
.collect();
assert_eq!(config_backed.len(), Group::ALL.len());
for ((section, group), expected) in config_backed.into_iter().zip(Group::ALL) {
// The reset button sends one ResetGroup per GUI section; if a
// section ever fails to map, its fields could never be reset.
assert_eq!(group, expected);
assert_eq!(
format!("{expected}"),
section.name().to_lowercase(),
"section {} diverged from group {}",
section.name(),
group
);
}
}

/// Exactly one section is exempt from the reset machinery, and its name
/// must stay stable because the shortcut hints and search rely on it.
#[test]
fn every_section_except_connection_maps_to_a_group() {
for section in Section::ALL {
match section.group() {
Some(_) => assert_ne!(section.name(), "Connection"),
None => assert_eq!(section.name(), "Connection"),
}
}
}

#[test]
fn every_field_has_a_nonempty_tooltip() {
for f in FIELDS {
assert!(!f.tip.is_empty(), "field {} needs a tooltip", f.label);
assert!(!f.label.is_empty());
}
}

#[test]
fn every_section_has_fields() {
for s in Section::ALL {
assert!(
FIELDS.iter().any(|f| f.section == s),
"section {} has no fields",
s.name()
);
}
}

/// Click-through was removed; no field may render it again.
#[test]
fn click_through_is_gone_from_the_field_registry() {
assert!(!FIELDS.iter().any(|f| f.label.contains("click")));
}

#[test]
fn anchor_field_is_registered_in_the_position_section() {
let field = FIELDS
.iter()
.find(|f| f.label == "anchor")
.expect("anchor field registered");
assert_eq!(field.section, Section::Position);
}
}
Loading
Loading