Show an active keybinding guide until disabled in Settings

This commit is contained in:
Samuel Onoja committed 2026-09-30 02:03:55 +01:00
1 parent 61870b9b6c
commit cc441c049e
12 files changed
+414 -79

No files matched your search

+13
View File
@@ -19,6 +19,7 @@ pub(crate) struct ShellConfig {
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub(crate) struct StatusConfig {
pub(crate) keybinding_guide: bool,
pub(crate) battery_percentage: bool,
pub(crate) window_title: bool,
pub(crate) low_battery_threshold: u8,
@@ -28,6 +29,7 @@ pub(crate) struct StatusConfig {
impl Default for StatusConfig {
fn default() -> Self {
Self {
keybinding_guide: true,
battery_percentage: true,
window_title: true,
low_battery_threshold: 20,
@@ -567,6 +569,17 @@ mod tests {
}
use super::*;
#[test]
fn keybinding_guide_is_enabled_until_explicitly_disabled() {
assert!(parse_source("").unwrap().status.keybinding_guide);
assert!(
!parse_source("status { keybinding-guide #false; }")
.unwrap()
.status
.keybinding_guide
);
}
#[test]
fn parses_theme_without_rejecting_compositor_sections() {
let config: FereseConfig = ferese_config::from_str(
@@ -0,0 +1,44 @@
use std::{os::unix::net::UnixStream, path::PathBuf, time::Duration};
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
pub(super) struct Entry {
pub keys: String,
pub description: String,
}
pub(super) fn load() -> Result<Vec<Entry>, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
kind: "command".into(),
command: "get-keybindings".into(),
args: serde_json::json!({}),
};
ferese_ipc::write_frame(&mut stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
let entries: Vec<Entry> = serde_json::from_value(response.result.ok_or("Missing keybindings")?)
.map_err(|error| error.to_string())?;
if entries.len() > 256
|| entries
.iter()
.any(|entry| entry.keys.len() > 256 || entry.description.len() > 512)
{
return Err("Invalid keybinding guide".into());
}
Ok(entries)
}
+88 -16
View File
@@ -1,5 +1,6 @@
mod config;
mod control;
mod keybinding_guide;
mod motion;
mod note_store;
mod notification_ui;
@@ -175,7 +176,10 @@ struct FereseShell {
calendar_offset: i32,
status_error: Option<String>,
menu: Option<status_ui::OpenMenu>,
power_modal: Option<system_modal::PowerModal>,
system_modal: Option<system_modal::SystemModal>,
guide_shown: bool,
guide_loading: bool,
guide_attempts: u8,
pending_power: Option<(window::Id, system_modal::PowerAction)>,
note_editor: Option<DesktopNoteEditor>,
note_drag: Option<NoteDrag>,
@@ -250,6 +254,8 @@ enum Message {
OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>),
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>),
Control(status::Action),
ShowGuide,
GuideLoaded(Result<Vec<keybinding_guide::Entry>, String>),
ConfirmPower(status::Action),
CancelPower,
ExecutePower,
@@ -292,7 +298,10 @@ impl cosmic::Application for FereseShell {
calendar_offset: 0,
status_error: None,
menu: None,
power_modal: None,
system_modal: None,
guide_shown: false,
guide_loading: false,
guide_attempts: 0,
pending_power: None,
note_editor: None,
note_drag: None,
@@ -331,7 +340,7 @@ impl cosmic::Application for FereseShell {
fn subscription(&self) -> Subscription<Self::Message> {
Subscription::batch([
if self
.power_modal
.system_modal
.as_ref()
.is_some_and(|modal| modal.motion.animating() || modal.motion.closing())
{
@@ -628,14 +637,67 @@ impl cosmic::Application for FereseShell {
self.open_menu(kind, anchor)
}
}
Message::ShowGuide => {
if self.guide_shown
|| self.guide_loading
|| self.guide_attempts >= 5
|| !self.config.status.keybinding_guide
|| self.outputs.is_empty()
{
return Task::none();
}
self.guide_loading = true;
self.guide_attempts += 1;
Task::perform(
async {
tokio::task::spawn_blocking(keybinding_guide::load)
.await
.map_err(|error| error.to_string())
.and_then(|result| result)
},
|result| cosmic::Action::App(Message::GuideLoaded(result)),
)
}
Message::GuideLoaded(result) => {
self.guide_loading = false;
match result {
Ok(entries)
if self.config.status.keybinding_guide
&& !self.outputs.is_empty()
&& self.system_modal.is_none()
&& self.pending_power.is_none() =>
{
self.guide_shown = true;
self.open_guide(entries)
}
result => {
if let Err(error) = result {
eprintln!("ferese-shell: keybinding guide unavailable: {error}");
}
if self.guide_attempts < 5
&& self.config.status.keybinding_guide
&& !self.guide_shown
{
Task::perform(
async {
tokio::time::sleep(Duration::from_secs(2)).await;
},
|_| cosmic::Action::App(Message::ShowGuide),
)
} else {
Task::none()
}
}
}
}
Message::ConfirmPower(action) => {
if let Some(action) = system_modal::PowerAction::from_status(action) {
return self.open_power_modal(action, None);
return self.open_system_modal(action, None);
}
Task::none()
}
Message::ExecutePower => self.execute_power_modal(),
Message::AnimatePower => self.animate_power_modal(),
Message::ExecutePower => self.execute_system_modal(),
Message::AnimatePower => self.animate_system_modal(),
Message::PowerCompleted(id, result) => self.finish_power_action(id, result),
Message::CalendarMonth(delta) => {
self.calendar_offset = (self.calendar_offset + delta).clamp(-1200, 1200);
@@ -645,10 +707,10 @@ impl cosmic::Application for FereseShell {
self.calendar_offset = 0;
Task::none()
}
Message::CancelPower => self.close_power_modal(),
Message::CancelPower => self.close_system_modal(),
Message::Control(action) => {
if let Some(action) = system_modal::PowerAction::from_status(action.clone()) {
return self.open_power_modal(action, None);
return self.open_system_modal(action, None);
}
if let status::Action::PowerProfile(profile) = action {
@@ -768,7 +830,7 @@ impl cosmic::Application for FereseShell {
if let Some((serial, output)) = poll.logout {
reload_task = Task::batch([
reload_task,
self.open_power_modal(
self.open_system_modal(
system_modal::PowerAction::Logout(serial),
Some(&output),
),
@@ -874,6 +936,15 @@ impl FereseShell {
Task::none()
}];
if !self.config.status.keybinding_guide
&& self
.system_modal
.as_ref()
.is_some_and(|modal| modal.is_guide())
{
tasks.push(self.destroy_system_modal(false));
}
if old.palette() != theme.palette() {
tasks.push(cosmic::command::set_theme(theme.palette().native_theme()));
}
@@ -941,7 +1012,7 @@ impl FereseShell {
let mut tasks = vec![
destroy_layer_surface(entry.bar),
menu,
self.destroy_power_modal(true),
self.rebuild_system_modal(),
];
if self.note_drag.as_ref().is_some_and(|drag| {
entry.clock == Some(drag.source)
@@ -1084,7 +1155,8 @@ impl FereseShell {
Task::batch(tasks),
self.rebuild_clocks(false),
self.rebuild_notes(false),
self.destroy_power_modal(true),
self.rebuild_system_modal(),
cosmic::task::message(cosmic::Action::App(Message::ShowGuide)),
])
}
@@ -1875,8 +1947,8 @@ impl FereseShell {
..
})
) {
if self.power_modal.is_some() {
return self.close_power_modal();
if self.system_modal.is_some() {
return self.close_system_modal();
}
if self.note_drag.is_some() {
return self.finish_note_drag(false);
@@ -1900,7 +1972,7 @@ impl FereseShell {
if self.outputs.iter().any(|entry| entry.bar == id)
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|| self
.power_modal
.system_modal
.as_ref()
.is_some_and(|modal| modal.contains(id))
|| self
@@ -1992,11 +2064,11 @@ impl FereseShell {
}
Event::Window(window::Event::Closed)
if self
.power_modal
.system_modal
.as_ref()
.is_some_and(|modal| modal.contains(id)) =>
{
self.close_power_modal()
self.close_system_modal()
}
Event::Window(window::Event::Closed) => Task::none(),
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
+145 -61
View File
@@ -54,27 +54,71 @@ struct ModalSurface {
regions: motion::Regions,
}
pub(super) struct PowerModal {
action: PowerAction,
#[derive(Clone, Debug, PartialEq)]
enum Content {
Power(PowerAction),
Guide(Vec<keybinding_guide::Entry>),
}
pub(super) struct SystemModal {
content: Content,
surfaces: Vec<ModalSurface>,
pub(super) motion: motion::PopupMotion,
error: Option<String>,
}
impl PowerModal {
impl SystemModal {
pub(super) fn is_guide(&self) -> bool {
matches!(self.content, Content::Guide(_))
}
pub(super) fn contains(&self, id: window::Id) -> bool {
self.surfaces.iter().any(|surface| surface.id == id)
}
}
impl FereseShell {
pub(super) fn open_power_modal(
pub(super) fn open_system_modal(
&mut self,
action: PowerAction,
output_name: Option<&str>,
) -> Task<Message> {
self.open_modal(Content::Power(action), output_name)
}
pub(super) fn rebuild_system_modal(&mut self) -> Task<Message> {
let guide = self
.system_modal
.as_ref()
.and_then(|modal| match &modal.content {
Content::Guide(entries)
if !modal.motion.closing() && self.config.status.keybinding_guide =>
{
Some(entries.clone())
}
_ => None,
});
let destroy = self.destroy_system_modal(true);
if let Some(entries) = guide {
if self.outputs.is_empty() {
self.guide_shown = false;
self.guide_attempts = 0;
return destroy;
}
let open = self.open_guide(entries);
Task::batch([destroy, open])
} else {
destroy
}
}
pub(super) fn open_guide(&mut self, entries: Vec<keybinding_guide::Entry>) -> Task<Message> {
self.open_modal(Content::Guide(entries), None)
}
fn open_modal(&mut self, content: Content, output_name: Option<&str>) -> Task<Message> {
if self.pending_power.is_some() || self.outputs.is_empty() {
if let PowerAction::Logout(serial) = action
if let Content::Power(PowerAction::Logout(serial)) = content
&& let Some(control) = &self.control
{
control.cancel_logout(serial);
@@ -82,9 +126,9 @@ impl FereseShell {
return Task::none();
}
if self
.power_modal
.system_modal
.as_ref()
.is_some_and(|modal| modal.action == action && !modal.motion.closing())
.is_some_and(|modal| modal.content == content && !modal.motion.closing())
{
return Task::none();
}
@@ -101,7 +145,7 @@ impl FereseShell {
})
.unwrap_or(0);
let mut tasks = vec![
self.destroy_power_modal(true),
self.destroy_system_modal(true),
self.destroy_menu(),
self.close_notification_history(),
];
@@ -135,12 +179,12 @@ impl FereseShell {
size: Some((None, None)),
..Default::default()
},
Some(Box::new(move |app| app.view_power_modal(id))),
Some(Box::new(move |app| app.view_system_modal(id))),
);
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
}
self.power_modal = Some(PowerModal {
action,
self.system_modal = Some(SystemModal {
content,
surfaces,
motion: motion::PopupMotion::new(self.config.animations),
error: None,
@@ -153,7 +197,7 @@ impl FereseShell {
id: window::Id,
surface: &wl_surface::WlSurface,
) {
let Some(modal) = &mut self.power_modal else {
let Some(modal) = &mut self.system_modal else {
return;
};
let Some(entry) = modal.surfaces.iter_mut().find(|entry| entry.id == id) else {
@@ -169,7 +213,7 @@ impl FereseShell {
}
pub(super) fn update_power_materials(&self) {
if let Some(modal) = &self.power_modal {
if let Some(modal) = &self.system_modal {
for entry in &modal.surfaces {
if let Some(effects) = &entry.effects {
let regions = entry.regions.lock().unwrap().clone();
@@ -181,36 +225,36 @@ impl FereseShell {
}
}
pub(super) fn close_power_modal(&mut self) -> Task<Message> {
if let Some(modal) = &mut self.power_modal {
if let PowerAction::Logout(serial) = modal.action
pub(super) fn close_system_modal(&mut self) -> Task<Message> {
if let Some(modal) = &mut self.system_modal {
if let Content::Power(PowerAction::Logout(serial)) = modal.content
&& let Some(control) = &self.control
{
control.cancel_logout(serial);
}
modal.motion.retarget(0.0, Instant::now());
}
self.animate_power_modal()
self.animate_system_modal()
}
pub(super) fn animate_power_modal(&mut self) -> Task<Message> {
pub(super) fn animate_system_modal(&mut self) -> Task<Message> {
if self
.power_modal
.system_modal
.as_ref()
.is_some_and(|modal| modal.motion.closing() && !modal.motion.animating())
{
return self.destroy_power_modal(false);
return self.destroy_system_modal(false);
}
self.update_power_materials();
Task::none()
}
pub(super) fn destroy_power_modal(&mut self, cancel: bool) -> Task<Message> {
let Some(modal) = self.power_modal.take() else {
pub(super) fn destroy_system_modal(&mut self, cancel: bool) -> Task<Message> {
let Some(modal) = self.system_modal.take() else {
return Task::none();
};
if cancel
&& let PowerAction::Logout(serial) = modal.action
&& let Content::Power(PowerAction::Logout(serial)) = modal.content
&& let Some(control) = &self.control
{
control.cancel_logout(serial);
@@ -225,28 +269,31 @@ impl FereseShell {
pub(super) fn cancel_logout_modal(&mut self, serial: u32) -> Task<Message> {
if self
.power_modal
.system_modal
.as_ref()
.is_some_and(|modal| modal.action == PowerAction::Logout(serial))
.is_some_and(|modal| modal.content == Content::Power(PowerAction::Logout(serial)))
{
self.destroy_power_modal(false)
self.destroy_system_modal(false)
} else {
Task::none()
}
}
pub(super) fn execute_power_modal(&mut self) -> Task<Message> {
let Some(modal) = &mut self.power_modal else {
pub(super) fn execute_system_modal(&mut self) -> Task<Message> {
let Some(modal) = &mut self.system_modal else {
return Task::none();
};
if modal.motion.closing() || self.pending_power.is_some() {
return Task::none();
}
let action = match modal.action {
let Content::Power(power) = modal.content else {
return self.close_system_modal();
};
let action = match power {
PowerAction::Logout(serial) => {
if let Some(control) = &self.control {
control.confirm_logout(serial);
return self.destroy_power_modal(false);
return self.destroy_system_modal(false);
}
modal.error = Some("Ferese shell control is unavailable".to_owned());
return Task::none();
@@ -256,8 +303,8 @@ impl FereseShell {
PowerAction::Suspend => status::Action::Suspend,
};
let id = modal.surfaces[0].id;
self.pending_power = Some((id, modal.action));
let destroy = self.destroy_power_modal(false);
self.pending_power = Some((id, power));
let destroy = self.destroy_system_modal(false);
let execute = Task::perform(
async move {
tokio::task::spawn_blocking(move || status::execute_power(action))
@@ -285,8 +332,8 @@ impl FereseShell {
match result {
Ok(()) => Task::none(),
Err(error) => {
let task = self.open_power_modal(action, None);
if let Some(modal) = &mut self.power_modal {
let task = self.open_system_modal(action, None);
if let Some(modal) = &mut self.system_modal {
modal.error = Some(error);
}
task
@@ -294,8 +341,8 @@ impl FereseShell {
}
}
fn view_power_modal(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.power_modal else {
fn view_system_modal(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.system_modal else {
return text("").into();
};
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
@@ -312,34 +359,71 @@ impl FereseShell {
palette.sidebar = palette.sidebar.scale_alpha(alpha);
palette.card = palette.card.scale_alpha(alpha);
palette.accent = palette.accent.scale_alpha(alpha);
let mut rows = column![
text(modal.action.title()).font(shell_font()).size(23),
text(modal.action.description())
.font(shell_font())
.size(14)
.class(theme::Text::Color(palette.muted)),
]
.spacing(12);
if let Some(error) = &modal.error {
rows = rows.push(text(error).size(13));
}
let confirm = button::text(modal.action.label())
.class(ferese_theme::controls::button_style(palette, true))
.on_press(cosmic::Action::App(Message::ExecutePower));
rows = rows.push(
row![
Space::new().width(Length::Fill),
button::text("Cancel")
.class(ferese_theme::controls::button_style(palette, false))
.on_press(cosmic::Action::App(Message::CancelPower)),
confirm,
]
.spacing(10),
);
let rows = match &modal.content {
Content::Power(action) => {
let mut rows = column![
text(action.title()).font(shell_font()).size(23),
text(action.description())
.font(shell_font())
.size(14)
.class(theme::Text::Color(palette.muted)),
]
.spacing(12);
if let Some(error) = &modal.error {
rows = rows.push(text(error).size(13));
}
rows.push(
row![
Space::new().width(Length::Fill),
button::text("Cancel")
.class(ferese_theme::controls::button_style(palette, false))
.on_press(cosmic::Action::App(Message::CancelPower)),
button::text(action.label())
.class(ferese_theme::controls::button_style(palette, true))
.on_press(cosmic::Action::App(Message::ExecutePower)),
]
.spacing(10),
)
}
Content::Guide(entries) => {
let mut bindings = column::with_capacity(entries.len()).spacing(12);
for entry in entries {
bindings = bindings.push(
row![
text(&entry.keys)
.font(shell_font())
.size(13)
.width(Length::FillPortion(1)),
text(&entry.description)
.font(shell_font())
.size(13)
.width(Length::FillPortion(1)),
]
.spacing(16),
);
}
let height = self
.outputs
.iter()
.filter_map(|output| output.size)
.map(|(_, height)| height)
.min()
.unwrap_or(720)
.saturating_sub(360)
.clamp(40, 400) as f32;
column![
text("Welcome to Ferese").font(shell_font()).size(23),
text("Your active shortcuts").font(shell_font()).size(14).class(theme::Text::Color(palette.muted)),
container(cosmic::widget::scrollable(bindings).height(Length::Shrink)).max_height(height),
text("Disable this guide in Settings → Shortcuts. It appears at each login until disabled.").font(shell_font()).size(12).class(theme::Text::Color(palette.muted)),
row![Space::new().width(Length::Fill), button::text("Got it").class(ferese_theme::controls::button_style(palette, true)).on_press(cosmic::Action::App(Message::CancelPower))],
].spacing(14)
}
};
container(rows)
.id("ferese-blur-card")
.width(Length::Fill)
.max_width(440)
.max_width(if modal.is_guide() { 600 } else { 440 })
.padding(24)
.class(theme::Container::custom(move |_| container::Style {
background: (!material).then_some(Background::Color(color_with_opacity(