Show an active keybinding guide until disabled in Settings
This commit is contained in:
1 parent
61870b9b6c
commit
cc441c049e
12 files changed
+397
-62
No files matched your search
Generated
+1
@@ -1771,6 +1771,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ferese-config",
|
"ferese-config",
|
||||||
"ferese-core",
|
"ferese-core",
|
||||||
|
"ferese-ipc",
|
||||||
"ferese-protocols",
|
"ferese-protocols",
|
||||||
"ferese-theme",
|
"ferese-theme",
|
||||||
"iced_accessibility",
|
"iced_accessibility",
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ target/release/ferese --backend nested --grant-effects --grant-shell-control --
|
|||||||
## Get around
|
## Get around
|
||||||
|
|
||||||
These are the built-in defaults; every shortcut and gesture can be changed.
|
These are the built-in defaults; every shortcut and gesture can be changed.
|
||||||
|
The login guide shows your active shortcuts. Disable it in **Settings → Shortcuts**
|
||||||
|
when you no longer need it.
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -566,6 +566,84 @@ impl Binding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option<serde_json::Value> {
|
||||||
|
let description = match &self.action {
|
||||||
|
BindingAction::None => return None,
|
||||||
|
BindingAction::Spawn(argv)
|
||||||
|
if argv
|
||||||
|
.first()
|
||||||
|
.is_some_and(|program| program == "ferese-screenshot") =>
|
||||||
|
{
|
||||||
|
if argv.iter().any(|arg| arg == "--full") {
|
||||||
|
"Capture whole screen"
|
||||||
|
} else {
|
||||||
|
"Capture selected area"
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
BindingAction::Spawn(argv) => format!(
|
||||||
|
"Launch {}",
|
||||||
|
argv.first()
|
||||||
|
.and_then(|program| std::path::Path::new(program).file_name())
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("application")
|
||||||
|
),
|
||||||
|
BindingAction::Close => "Close window".into(),
|
||||||
|
BindingAction::Exit => "Log out (with confirmation)".into(),
|
||||||
|
BindingAction::Focus(direction) => format!("Focus window {direction:?}"),
|
||||||
|
BindingAction::Move(direction) => format!("Move window {direction:?}"),
|
||||||
|
BindingAction::Resize(direction) => format!("Resize window {direction:?}"),
|
||||||
|
BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"),
|
||||||
|
BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"),
|
||||||
|
BindingAction::SwitchRelativeWorkspace(next) => if *next {
|
||||||
|
"Next workspace"
|
||||||
|
} else {
|
||||||
|
"Previous workspace"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
BindingAction::ToggleFullscreen => "Toggle fullscreen".into(),
|
||||||
|
BindingAction::ToggleMaximized => "Toggle full-width window".into(),
|
||||||
|
BindingAction::ToggleLayout => "Switch workspace layout".into(),
|
||||||
|
BindingAction::CycleColumnWidth => "Cycle column width".into(),
|
||||||
|
BindingAction::CenterColumn => "Center focused column".into(),
|
||||||
|
BindingAction::Consume => "Join window into column".into(),
|
||||||
|
BindingAction::Expel => "Move window out of column".into(),
|
||||||
|
BindingAction::ToggleFloating => "Toggle floating window".into(),
|
||||||
|
BindingAction::ToggleOverview => "Open or close overview".into(),
|
||||||
|
};
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for (enabled, label) in [
|
||||||
|
(self.modifiers.logo, "Super"),
|
||||||
|
(self.modifiers.ctrl, "Ctrl"),
|
||||||
|
(self.modifiers.alt, "Alt"),
|
||||||
|
(self.modifiers.shift, "Shift"),
|
||||||
|
] {
|
||||||
|
if enabled {
|
||||||
|
parts.push(label.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(match self.trigger {
|
||||||
|
BindingTrigger::Keysym(symbol) => {
|
||||||
|
let name = xkb::keysym_get_name(xkb::Keysym::new(symbol));
|
||||||
|
match name.as_str() {
|
||||||
|
"Return" => "Enter".into(),
|
||||||
|
"Print" => "Print Screen".into(),
|
||||||
|
"space" => "Space".into(),
|
||||||
|
_ if name.len() == 1 => name.to_uppercase(),
|
||||||
|
_ => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BindingTrigger::Physical(code) => keymap
|
||||||
|
.and_then(|map| map.key_get_name(code))
|
||||||
|
.map(|name| format!("Physical {name}"))
|
||||||
|
.unwrap_or_else(|| format!("Keycode {}", u32::from(code))),
|
||||||
|
BindingTrigger::Swipe { fingers, direction } => {
|
||||||
|
format!("{fingers}-finger swipe {direction:?}")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Some(serde_json::json!({"keys": parts.join(" + "), "description": description}))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn swipe_fingers(&self, count: u32) -> bool {
|
pub(crate) fn swipe_fingers(&self, count: u32) -> bool {
|
||||||
matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count)
|
matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count)
|
||||||
}
|
}
|
||||||
@@ -2352,6 +2430,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guide_uses_effective_bindings_including_overrides_and_unbindings() {
|
||||||
|
let config = parse(
|
||||||
|
"binding keys=\"Super+Q\" action=\"none\"\nbinding keys=\"Super+Tab\" action=\"toggle-floating\"\n",
|
||||||
|
);
|
||||||
|
let input = config.input_settings().unwrap();
|
||||||
|
let map = physical_keymap(&input).unwrap();
|
||||||
|
let entries = config
|
||||||
|
.bindings(&input)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|binding| binding.guide_entry(Some(&map)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(
|
||||||
|
!entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry["description"] == "Close window")
|
||||||
|
);
|
||||||
|
assert!(entries.iter().any(|entry| entry["keys"] == "Super + Tab"
|
||||||
|
&& entry["description"] == "Toggle floating window"));
|
||||||
|
assert!(entries.iter().any(|entry| entry["keys"] == "Super + Enter"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn supplies_complete_v0_bindings_and_terminal_command() {
|
fn supplies_complete_v0_bindings_and_terminal_command() {
|
||||||
let config = parse("");
|
let config = parse("");
|
||||||
|
|||||||
@@ -414,6 +414,16 @@ impl Ferese {
|
|||||||
"close" => self.close_focused_window(),
|
"close" => self.close_focused_window(),
|
||||||
"get-focused-window" => return Ok(self.focused_window_json()),
|
"get-focused-window" => return Ok(self.focused_window_json()),
|
||||||
"get-windows" => return Ok(self.windows_json()),
|
"get-windows" => return Ok(self.windows_json()),
|
||||||
|
"get-keybindings" => {
|
||||||
|
let map = crate::config::physical_keymap(&self.input_settings).ok();
|
||||||
|
return Ok(json!(
|
||||||
|
self.bindings
|
||||||
|
.iter()
|
||||||
|
.filter_map(|binding| binding.guide_entry(map.as_ref()))
|
||||||
|
.take(256)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
));
|
||||||
|
}
|
||||||
"has-client-surfaces" => {
|
"has-client-surfaces" => {
|
||||||
let pid = args
|
let pid = args
|
||||||
.get("pid")
|
.get("pid")
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Backlog
|
# Backlog
|
||||||
|
|
||||||
- [ ] Add a first-run keybinding guide modal showing important keybindings and their functions. Keep showing it until the user disables the guide in Settings.
|
- [ ] Review the lock screen against swaylock for practical feature parity: dim and sleep after inactivity, and reduce the oversized password input. Keep the implementation simple.
|
||||||
@@ -389,6 +389,7 @@ returns. Ferese does not change the system's suspend policy.
|
|||||||
|
|
||||||
| `status` key | Type | Default | Meaning |
|
| `status` key | Type | Default | Meaning |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
| `keybinding-guide` | boolean | `true` | Show the active shortcut guide at login until disabled in Settings → Shortcuts. |
|
||||||
| `window_title` | boolean | `true` | Focused window title in the bar center when space allows |
|
| `window_title` | boolean | `true` | Focused window title in the bar center when space allows |
|
||||||
| `battery_percentage` | boolean | `true` | Show percentage beside icon |
|
| `battery_percentage` | boolean | `true` | Show percentage beside icon |
|
||||||
| `low_battery_threshold` | integer 0–100 | `20` | Warning-color threshold |
|
| `low_battery_threshold` | integer 0–100 | `20` | Warning-color threshold |
|
||||||
|
|||||||
@@ -950,6 +950,12 @@ pub fn fields(page: Page) -> Vec<Field> {
|
|||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
Page::Shortcuts | Page::Startup | Page::Displays => vec![],
|
Page::Shortcuts => vec![toggle(
|
||||||
|
"status.keybinding_guide",
|
||||||
|
"Show keybinding guide at login",
|
||||||
|
"Keep the shortcut guide until you are comfortable with Ferese.",
|
||||||
|
true,
|
||||||
|
)],
|
||||||
|
Page::Startup | Page::Displays => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
rust-version = "1.93"
|
rust-version = "1.93"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ferese-ipc = { path = "../../crates/ferese-ipc" }
|
||||||
ferese-theme = { path = "../../crates/ferese-theme" }
|
ferese-theme = { path = "../../crates/ferese-theme" }
|
||||||
ferese-config = { path = "../../crates/ferese-config" }
|
ferese-config = { path = "../../crates/ferese-config" }
|
||||||
ferese-core = { path = "../../crates/ferese-core" }
|
ferese-core = { path = "../../crates/ferese-core" }
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub(crate) struct ShellConfig {
|
|||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) struct StatusConfig {
|
pub(crate) struct StatusConfig {
|
||||||
|
pub(crate) keybinding_guide: bool,
|
||||||
pub(crate) battery_percentage: bool,
|
pub(crate) battery_percentage: bool,
|
||||||
pub(crate) window_title: bool,
|
pub(crate) window_title: bool,
|
||||||
pub(crate) low_battery_threshold: u8,
|
pub(crate) low_battery_threshold: u8,
|
||||||
@@ -28,6 +29,7 @@ pub(crate) struct StatusConfig {
|
|||||||
impl Default for StatusConfig {
|
impl Default for StatusConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
keybinding_guide: true,
|
||||||
battery_percentage: true,
|
battery_percentage: true,
|
||||||
window_title: true,
|
window_title: true,
|
||||||
low_battery_threshold: 20,
|
low_battery_threshold: 20,
|
||||||
@@ -567,6 +569,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn parses_theme_without_rejecting_compositor_sections() {
|
fn parses_theme_without_rejecting_compositor_sections() {
|
||||||
let config: FereseConfig = ferese_config::from_str(
|
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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod config;
|
mod config;
|
||||||
mod control;
|
mod control;
|
||||||
|
mod keybinding_guide;
|
||||||
mod motion;
|
mod motion;
|
||||||
mod note_store;
|
mod note_store;
|
||||||
mod notification_ui;
|
mod notification_ui;
|
||||||
@@ -175,7 +176,10 @@ struct FereseShell {
|
|||||||
calendar_offset: i32,
|
calendar_offset: i32,
|
||||||
status_error: Option<String>,
|
status_error: Option<String>,
|
||||||
menu: Option<status_ui::OpenMenu>,
|
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)>,
|
pending_power: Option<(window::Id, system_modal::PowerAction)>,
|
||||||
note_editor: Option<DesktopNoteEditor>,
|
note_editor: Option<DesktopNoteEditor>,
|
||||||
note_drag: Option<NoteDrag>,
|
note_drag: Option<NoteDrag>,
|
||||||
@@ -250,6 +254,8 @@ enum Message {
|
|||||||
OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>),
|
OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>),
|
||||||
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>),
|
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>),
|
||||||
Control(status::Action),
|
Control(status::Action),
|
||||||
|
ShowGuide,
|
||||||
|
GuideLoaded(Result<Vec<keybinding_guide::Entry>, String>),
|
||||||
ConfirmPower(status::Action),
|
ConfirmPower(status::Action),
|
||||||
CancelPower,
|
CancelPower,
|
||||||
ExecutePower,
|
ExecutePower,
|
||||||
@@ -292,7 +298,10 @@ impl cosmic::Application for FereseShell {
|
|||||||
calendar_offset: 0,
|
calendar_offset: 0,
|
||||||
status_error: None,
|
status_error: None,
|
||||||
menu: None,
|
menu: None,
|
||||||
power_modal: None,
|
system_modal: None,
|
||||||
|
guide_shown: false,
|
||||||
|
guide_loading: false,
|
||||||
|
guide_attempts: 0,
|
||||||
pending_power: None,
|
pending_power: None,
|
||||||
note_editor: None,
|
note_editor: None,
|
||||||
note_drag: None,
|
note_drag: None,
|
||||||
@@ -331,7 +340,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
fn subscription(&self) -> Subscription<Self::Message> {
|
fn subscription(&self) -> Subscription<Self::Message> {
|
||||||
Subscription::batch([
|
Subscription::batch([
|
||||||
if self
|
if self
|
||||||
.power_modal
|
.system_modal
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|modal| modal.motion.animating() || modal.motion.closing())
|
.is_some_and(|modal| modal.motion.animating() || modal.motion.closing())
|
||||||
{
|
{
|
||||||
@@ -628,14 +637,67 @@ impl cosmic::Application for FereseShell {
|
|||||||
self.open_menu(kind, anchor)
|
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) => {
|
Message::ConfirmPower(action) => {
|
||||||
if let Some(action) = system_modal::PowerAction::from_status(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()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ExecutePower => self.execute_power_modal(),
|
Message::ExecutePower => self.execute_system_modal(),
|
||||||
Message::AnimatePower => self.animate_power_modal(),
|
Message::AnimatePower => self.animate_system_modal(),
|
||||||
Message::PowerCompleted(id, result) => self.finish_power_action(id, result),
|
Message::PowerCompleted(id, result) => self.finish_power_action(id, result),
|
||||||
Message::CalendarMonth(delta) => {
|
Message::CalendarMonth(delta) => {
|
||||||
self.calendar_offset = (self.calendar_offset + delta).clamp(-1200, 1200);
|
self.calendar_offset = (self.calendar_offset + delta).clamp(-1200, 1200);
|
||||||
@@ -645,10 +707,10 @@ impl cosmic::Application for FereseShell {
|
|||||||
self.calendar_offset = 0;
|
self.calendar_offset = 0;
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::CancelPower => self.close_power_modal(),
|
Message::CancelPower => self.close_system_modal(),
|
||||||
Message::Control(action) => {
|
Message::Control(action) => {
|
||||||
if let Some(action) = system_modal::PowerAction::from_status(action.clone()) {
|
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 {
|
if let status::Action::PowerProfile(profile) = action {
|
||||||
@@ -768,7 +830,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
if let Some((serial, output)) = poll.logout {
|
if let Some((serial, output)) = poll.logout {
|
||||||
reload_task = Task::batch([
|
reload_task = Task::batch([
|
||||||
reload_task,
|
reload_task,
|
||||||
self.open_power_modal(
|
self.open_system_modal(
|
||||||
system_modal::PowerAction::Logout(serial),
|
system_modal::PowerAction::Logout(serial),
|
||||||
Some(&output),
|
Some(&output),
|
||||||
),
|
),
|
||||||
@@ -874,6 +936,15 @@ impl FereseShell {
|
|||||||
Task::none()
|
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() {
|
if old.palette() != theme.palette() {
|
||||||
tasks.push(cosmic::command::set_theme(theme.palette().native_theme()));
|
tasks.push(cosmic::command::set_theme(theme.palette().native_theme()));
|
||||||
}
|
}
|
||||||
@@ -941,7 +1012,7 @@ impl FereseShell {
|
|||||||
let mut tasks = vec![
|
let mut tasks = vec![
|
||||||
destroy_layer_surface(entry.bar),
|
destroy_layer_surface(entry.bar),
|
||||||
menu,
|
menu,
|
||||||
self.destroy_power_modal(true),
|
self.rebuild_system_modal(),
|
||||||
];
|
];
|
||||||
if self.note_drag.as_ref().is_some_and(|drag| {
|
if self.note_drag.as_ref().is_some_and(|drag| {
|
||||||
entry.clock == Some(drag.source)
|
entry.clock == Some(drag.source)
|
||||||
@@ -1084,7 +1155,8 @@ impl FereseShell {
|
|||||||
Task::batch(tasks),
|
Task::batch(tasks),
|
||||||
self.rebuild_clocks(false),
|
self.rebuild_clocks(false),
|
||||||
self.rebuild_notes(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() {
|
if self.system_modal.is_some() {
|
||||||
return self.close_power_modal();
|
return self.close_system_modal();
|
||||||
}
|
}
|
||||||
if self.note_drag.is_some() {
|
if self.note_drag.is_some() {
|
||||||
return self.finish_note_drag(false);
|
return self.finish_note_drag(false);
|
||||||
@@ -1900,7 +1972,7 @@ impl FereseShell {
|
|||||||
if self.outputs.iter().any(|entry| entry.bar == id)
|
if self.outputs.iter().any(|entry| entry.bar == id)
|
||||||
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|
||||||
|| self
|
|| self
|
||||||
.power_modal
|
.system_modal
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|modal| modal.contains(id))
|
.is_some_and(|modal| modal.contains(id))
|
||||||
|| self
|
|| self
|
||||||
@@ -1992,11 +2064,11 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
Event::Window(window::Event::Closed)
|
Event::Window(window::Event::Closed)
|
||||||
if self
|
if self
|
||||||
.power_modal
|
.system_modal
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|modal| modal.contains(id)) =>
|
.is_some_and(|modal| modal.contains(id)) =>
|
||||||
{
|
{
|
||||||
self.close_power_modal()
|
self.close_system_modal()
|
||||||
}
|
}
|
||||||
Event::Window(window::Event::Closed) => Task::none(),
|
Event::Window(window::Event::Closed) => Task::none(),
|
||||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
|
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
|
||||||
|
|||||||
@@ -54,27 +54,71 @@ struct ModalSurface {
|
|||||||
regions: motion::Regions,
|
regions: motion::Regions,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct PowerModal {
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
action: PowerAction,
|
enum Content {
|
||||||
|
Power(PowerAction),
|
||||||
|
Guide(Vec<keybinding_guide::Entry>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct SystemModal {
|
||||||
|
content: Content,
|
||||||
surfaces: Vec<ModalSurface>,
|
surfaces: Vec<ModalSurface>,
|
||||||
pub(super) motion: motion::PopupMotion,
|
pub(super) motion: motion::PopupMotion,
|
||||||
error: Option<String>,
|
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 {
|
pub(super) fn contains(&self, id: window::Id) -> bool {
|
||||||
self.surfaces.iter().any(|surface| surface.id == id)
|
self.surfaces.iter().any(|surface| surface.id == id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FereseShell {
|
impl FereseShell {
|
||||||
pub(super) fn open_power_modal(
|
pub(super) fn open_system_modal(
|
||||||
&mut self,
|
&mut self,
|
||||||
action: PowerAction,
|
action: PowerAction,
|
||||||
output_name: Option<&str>,
|
output_name: Option<&str>,
|
||||||
) -> Task<Message> {
|
) -> 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 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
|
&& let Some(control) = &self.control
|
||||||
{
|
{
|
||||||
control.cancel_logout(serial);
|
control.cancel_logout(serial);
|
||||||
@@ -82,9 +126,9 @@ impl FereseShell {
|
|||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
if self
|
if self
|
||||||
.power_modal
|
.system_modal
|
||||||
.as_ref()
|
.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();
|
return Task::none();
|
||||||
}
|
}
|
||||||
@@ -101,7 +145,7 @@ impl FereseShell {
|
|||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let mut tasks = vec![
|
let mut tasks = vec![
|
||||||
self.destroy_power_modal(true),
|
self.destroy_system_modal(true),
|
||||||
self.destroy_menu(),
|
self.destroy_menu(),
|
||||||
self.close_notification_history(),
|
self.close_notification_history(),
|
||||||
];
|
];
|
||||||
@@ -135,12 +179,12 @@ impl FereseShell {
|
|||||||
size: Some((None, None)),
|
size: Some((None, None)),
|
||||||
..Default::default()
|
..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)));
|
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
|
||||||
}
|
}
|
||||||
self.power_modal = Some(PowerModal {
|
self.system_modal = Some(SystemModal {
|
||||||
action,
|
content,
|
||||||
surfaces,
|
surfaces,
|
||||||
motion: motion::PopupMotion::new(self.config.animations),
|
motion: motion::PopupMotion::new(self.config.animations),
|
||||||
error: None,
|
error: None,
|
||||||
@@ -153,7 +197,7 @@ impl FereseShell {
|
|||||||
id: window::Id,
|
id: window::Id,
|
||||||
surface: &wl_surface::WlSurface,
|
surface: &wl_surface::WlSurface,
|
||||||
) {
|
) {
|
||||||
let Some(modal) = &mut self.power_modal else {
|
let Some(modal) = &mut self.system_modal else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(entry) = modal.surfaces.iter_mut().find(|entry| entry.id == id) else {
|
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) {
|
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 {
|
for entry in &modal.surfaces {
|
||||||
if let Some(effects) = &entry.effects {
|
if let Some(effects) = &entry.effects {
|
||||||
let regions = entry.regions.lock().unwrap().clone();
|
let regions = entry.regions.lock().unwrap().clone();
|
||||||
@@ -181,36 +225,36 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn close_power_modal(&mut self) -> Task<Message> {
|
pub(super) fn close_system_modal(&mut self) -> Task<Message> {
|
||||||
if let Some(modal) = &mut self.power_modal {
|
if let Some(modal) = &mut self.system_modal {
|
||||||
if let PowerAction::Logout(serial) = modal.action
|
if let Content::Power(PowerAction::Logout(serial)) = modal.content
|
||||||
&& let Some(control) = &self.control
|
&& let Some(control) = &self.control
|
||||||
{
|
{
|
||||||
control.cancel_logout(serial);
|
control.cancel_logout(serial);
|
||||||
}
|
}
|
||||||
modal.motion.retarget(0.0, Instant::now());
|
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
|
if self
|
||||||
.power_modal
|
.system_modal
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|modal| modal.motion.closing() && !modal.motion.animating())
|
.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();
|
self.update_power_materials();
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn destroy_power_modal(&mut self, cancel: bool) -> Task<Message> {
|
pub(super) fn destroy_system_modal(&mut self, cancel: bool) -> Task<Message> {
|
||||||
let Some(modal) = self.power_modal.take() else {
|
let Some(modal) = self.system_modal.take() else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
if cancel
|
if cancel
|
||||||
&& let PowerAction::Logout(serial) = modal.action
|
&& let Content::Power(PowerAction::Logout(serial)) = modal.content
|
||||||
&& let Some(control) = &self.control
|
&& let Some(control) = &self.control
|
||||||
{
|
{
|
||||||
control.cancel_logout(serial);
|
control.cancel_logout(serial);
|
||||||
@@ -225,28 +269,31 @@ impl FereseShell {
|
|||||||
|
|
||||||
pub(super) fn cancel_logout_modal(&mut self, serial: u32) -> Task<Message> {
|
pub(super) fn cancel_logout_modal(&mut self, serial: u32) -> Task<Message> {
|
||||||
if self
|
if self
|
||||||
.power_modal
|
.system_modal
|
||||||
.as_ref()
|
.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 {
|
} else {
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn execute_power_modal(&mut self) -> Task<Message> {
|
pub(super) fn execute_system_modal(&mut self) -> Task<Message> {
|
||||||
let Some(modal) = &mut self.power_modal else {
|
let Some(modal) = &mut self.system_modal else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
if modal.motion.closing() || self.pending_power.is_some() {
|
if modal.motion.closing() || self.pending_power.is_some() {
|
||||||
return Task::none();
|
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) => {
|
PowerAction::Logout(serial) => {
|
||||||
if let Some(control) = &self.control {
|
if let Some(control) = &self.control {
|
||||||
control.confirm_logout(serial);
|
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());
|
modal.error = Some("Ferese shell control is unavailable".to_owned());
|
||||||
return Task::none();
|
return Task::none();
|
||||||
@@ -256,8 +303,8 @@ impl FereseShell {
|
|||||||
PowerAction::Suspend => status::Action::Suspend,
|
PowerAction::Suspend => status::Action::Suspend,
|
||||||
};
|
};
|
||||||
let id = modal.surfaces[0].id;
|
let id = modal.surfaces[0].id;
|
||||||
self.pending_power = Some((id, modal.action));
|
self.pending_power = Some((id, power));
|
||||||
let destroy = self.destroy_power_modal(false);
|
let destroy = self.destroy_system_modal(false);
|
||||||
let execute = Task::perform(
|
let execute = Task::perform(
|
||||||
async move {
|
async move {
|
||||||
tokio::task::spawn_blocking(move || status::execute_power(action))
|
tokio::task::spawn_blocking(move || status::execute_power(action))
|
||||||
@@ -285,8 +332,8 @@ impl FereseShell {
|
|||||||
match result {
|
match result {
|
||||||
Ok(()) => Task::none(),
|
Ok(()) => Task::none(),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let task = self.open_power_modal(action, None);
|
let task = self.open_system_modal(action, None);
|
||||||
if let Some(modal) = &mut self.power_modal {
|
if let Some(modal) = &mut self.system_modal {
|
||||||
modal.error = Some(error);
|
modal.error = Some(error);
|
||||||
}
|
}
|
||||||
task
|
task
|
||||||
@@ -294,8 +341,8 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_power_modal(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
fn view_system_modal(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
||||||
let Some(modal) = &self.power_modal else {
|
let Some(modal) = &self.system_modal else {
|
||||||
return text("").into();
|
return text("").into();
|
||||||
};
|
};
|
||||||
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
|
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
|
||||||
@@ -312,9 +359,11 @@ impl FereseShell {
|
|||||||
palette.sidebar = palette.sidebar.scale_alpha(alpha);
|
palette.sidebar = palette.sidebar.scale_alpha(alpha);
|
||||||
palette.card = palette.card.scale_alpha(alpha);
|
palette.card = palette.card.scale_alpha(alpha);
|
||||||
palette.accent = palette.accent.scale_alpha(alpha);
|
palette.accent = palette.accent.scale_alpha(alpha);
|
||||||
|
let rows = match &modal.content {
|
||||||
|
Content::Power(action) => {
|
||||||
let mut rows = column![
|
let mut rows = column![
|
||||||
text(modal.action.title()).font(shell_font()).size(23),
|
text(action.title()).font(shell_font()).size(23),
|
||||||
text(modal.action.description())
|
text(action.description())
|
||||||
.font(shell_font())
|
.font(shell_font())
|
||||||
.size(14)
|
.size(14)
|
||||||
.class(theme::Text::Color(palette.muted)),
|
.class(theme::Text::Color(palette.muted)),
|
||||||
@@ -323,23 +372,58 @@ impl FereseShell {
|
|||||||
if let Some(error) = &modal.error {
|
if let Some(error) = &modal.error {
|
||||||
rows = rows.push(text(error).size(13));
|
rows = rows.push(text(error).size(13));
|
||||||
}
|
}
|
||||||
let confirm = button::text(modal.action.label())
|
rows.push(
|
||||||
.class(ferese_theme::controls::button_style(palette, true))
|
|
||||||
.on_press(cosmic::Action::App(Message::ExecutePower));
|
|
||||||
rows = rows.push(
|
|
||||||
row![
|
row![
|
||||||
Space::new().width(Length::Fill),
|
Space::new().width(Length::Fill),
|
||||||
button::text("Cancel")
|
button::text("Cancel")
|
||||||
.class(ferese_theme::controls::button_style(palette, false))
|
.class(ferese_theme::controls::button_style(palette, false))
|
||||||
.on_press(cosmic::Action::App(Message::CancelPower)),
|
.on_press(cosmic::Action::App(Message::CancelPower)),
|
||||||
confirm,
|
button::text(action.label())
|
||||||
|
.class(ferese_theme::controls::button_style(palette, true))
|
||||||
|
.on_press(cosmic::Action::App(Message::ExecutePower)),
|
||||||
]
|
]
|
||||||
.spacing(10),
|
.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)
|
container(rows)
|
||||||
.id("ferese-blur-card")
|
.id("ferese-blur-card")
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.max_width(440)
|
.max_width(if modal.is_guide() { 600 } else { 440 })
|
||||||
.padding(24)
|
.padding(24)
|
||||||
.class(theme::Container::custom(move |_| container::Style {
|
.class(theme::Container::custom(move |_| container::Style {
|
||||||
background: (!material).then_some(Background::Color(color_with_opacity(
|
background: (!material).then_some(Background::Color(color_with_opacity(
|
||||||
|
|||||||
Reference in new issue
Block a user