cosmic-settings/cosmic-settings/src/pages/input/keyboard/shortcuts/common.rs
2024-10-29 13:52:21 +01:00

613 lines
22 KiB
Rust

use cosmic::iced::alignment::Horizontal;
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{self, button, icon, settings, text};
use cosmic::{theme, Apply, Element, Task};
use cosmic_config::{ConfigGet, ConfigSet};
use cosmic_settings_config::shortcuts::{self, Action, Binding, Shortcuts};
use cosmic_settings_page as page;
use slab::Slab;
use slotmap::Key;
use std::borrow::Cow;
use std::io;
use std::str::FromStr;
#[derive(Clone, Debug)]
pub enum ShortcutMessage {
AddKeybinding,
ApplyReplace,
CancelReplace,
DeleteBinding(usize),
DeleteShortcut(usize),
EditBinding(usize, bool),
InputBinding(usize, String),
ResetBindings,
ShowShortcut(usize, String),
SubmitBinding(usize),
}
#[derive(Debug)]
pub struct ShortcutBinding {
pub id: widget::Id,
pub binding: Binding,
pub input: String,
pub editing: bool,
pub is_default: bool,
}
#[must_use]
#[derive(Debug)]
pub struct ShortcutModel {
pub action: Action,
pub bindings: Slab<ShortcutBinding>,
pub description: String,
pub modified: u16,
}
impl ShortcutModel {
pub fn new(defaults: &Shortcuts, shortcuts: &Shortcuts, action: Action) -> Self {
let (bindings, modified) =
shortcuts
.shortcuts(&action)
.fold((Slab::new(), 0), |(mut slab, modified), binding| {
let is_default = defaults.0.get(binding) == Some(&action);
slab.insert(ShortcutBinding {
id: widget::Id::unique(),
binding: binding.clone(),
input: String::new(),
editing: false,
is_default,
});
(slab, if is_default { modified } else { modified + 1 })
});
let mut localized_description = super::localize_action(&action);
if let Action::Spawn(_) = &action {
localized_description = bindings
.iter()
.map(|(_, shortcut)| super::localize_custom_action(&action, &shortcut.binding))
.take(1)
.collect();
}
Self {
description: localized_description,
modified: defaults.0.iter().filter(|(_, a)| **a == action).fold(
modified,
|modified, (binding, _)| {
if bindings.iter().any(|(_, model)| model.binding == *binding) {
modified
} else {
modified + 1
}
},
),
action,
bindings,
}
}
}
#[must_use]
pub struct Model {
pub entity: page::Entity,
pub defaults: Shortcuts,
pub replace_dialog: Option<(usize, Binding, Action, String)>,
pub shortcut_models: Slab<ShortcutModel>,
pub shortcut_context: Option<usize>,
pub config: cosmic_config::Config,
pub custom: bool,
pub actions: fn(&Shortcuts, &Shortcuts) -> Slab<ShortcutModel>,
}
impl Default for Model {
fn default() -> Self {
Self {
entity: page::Entity::null(),
defaults: Shortcuts::default(),
replace_dialog: None,
shortcut_models: Slab::new(),
shortcut_context: None,
config: shortcuts::context().unwrap(),
custom: false,
actions: |_, _| Slab::new(),
}
}
}
impl Model {
pub fn actions(mut self, actions: fn(&Shortcuts, &Shortcuts) -> Slab<ShortcutModel>) -> Self {
self.actions = actions;
self
}
pub fn custom(mut self) -> Self {
self.custom = true;
self
}
/// Adds a new binding to the shortcuts config
pub(super) fn config_add(&self, action: Action, binding: Binding) {
let mut shortcuts = self.shortcuts_config();
shortcuts.0.insert(binding, action);
self.shortcuts_config_set(shortcuts);
}
/// Check if a binding is already set
pub(super) fn config_contains(&self, binding: &Binding) -> Option<Action> {
self.shortcuts_system_config()
.0
.get(binding)
.cloned()
.filter(|action| *action != Action::Disable)
}
/// Removes a binding from the shortcuts config
pub(super) fn config_remove(&self, binding: &Binding) {
let mut shortcuts = self.shortcuts_config();
shortcuts.0.retain(|b, _| b != binding);
self.shortcuts_config_set(shortcuts);
}
pub(super) fn context_drawer(&self) -> Option<Element<'_, ShortcutMessage>> {
self.shortcut_context
.as_ref()
.map(|id| context_drawer(&self.shortcut_models, *id, self.custom))
}
pub(super) fn dialog(&self) -> Option<Element<'_, ShortcutMessage>> {
if let Some(&(id, _, _, ref action)) = self.replace_dialog.as_ref() {
if let Some(short_id) = self.shortcut_context {
if let Some(model) = self.shortcut_models.get(short_id) {
if let Some(shortcut) = model.bindings.get(id) {
let primary_action = button::suggested(fl!("replace"))
.on_press(ShortcutMessage::ApplyReplace);
let secondary_action = button::standard(fl!("cancel"))
.on_press(ShortcutMessage::CancelReplace);
let dialog = widget::dialog(fl!("replace-shortcut-dialog"))
.icon(icon::from_name("dialog-warning").size(64))
.body(fl!(
"replace-shortcut-dialog",
"desc",
shortcut = shortcut.input.clone(),
name = shortcut
.binding
.description
.as_ref()
.unwrap_or(action)
.to_owned()
))
.primary_action(primary_action)
.secondary_action(secondary_action);
return Some(dialog.into());
}
}
}
}
None
}
pub(super) fn on_enter(&mut self) {
let mut shortcuts = self.config.get::<Shortcuts>("defaults").unwrap_or_default();
self.defaults = shortcuts.clone();
if let Ok(custom) = self.config.get::<Shortcuts>("custom") {
for (binding, action) in custom.0 {
shortcuts.0.remove(&binding);
shortcuts.0.insert(binding, action);
}
}
self.shortcut_models = (self.actions)(&self.defaults, &shortcuts);
}
pub(super) fn on_clear(&mut self) {
self.shortcut_models.clear();
}
/// Gets the custom configuration for keyboard shortcuts.
pub(super) fn shortcuts_config(&self) -> Shortcuts {
match self.config.get::<Shortcuts>("custom") {
Ok(shortcuts) => shortcuts,
Err(cosmic_config::Error::GetKey(_, why)) if why.kind() == io::ErrorKind::NotFound => {
Shortcuts::default()
}
Err(why) => {
tracing::error!(?why, "unable to get the current shortcuts config");
Shortcuts::default()
}
}
}
/// Gets the system configuration for keyboard shortcuts.
pub(super) fn shortcuts_system_config(&self) -> Shortcuts {
let mut shortcuts = self.config.get::<Shortcuts>("defaults").unwrap_or_default();
if let Ok(custom) = self.config.get::<Shortcuts>("custom") {
shortcuts.0.extend(custom.0);
}
shortcuts
}
/// Writes a new configuration to the keyboard shortcuts config file.
pub(super) fn shortcuts_config_set(&self, shortcuts: Shortcuts) {
if let Err(why) = self.config.set("custom", shortcuts) {
tracing::error!(?why, "failed to write shortcuts config");
}
}
#[allow(clippy::too_many_lines)]
pub(super) fn update(&mut self, message: ShortcutMessage) -> Task<crate::app::Message> {
match message {
ShortcutMessage::AddKeybinding => {
if let Some(short_id) = self.shortcut_context {
if let Some(model) = self.shortcut_models.get_mut(short_id) {
// If an empty entry exists, focus it instead of creating a new input.
for (_, shortcut) in &mut model.bindings {
if shortcut.binding.is_set()
|| Binding::from_str(&shortcut.input).is_ok()
{
continue;
}
shortcut.input.clear();
return widget::text_input::focus(shortcut.id.clone());
}
// Create a new input and focus it.
let id = widget::Id::unique();
model.bindings.insert(ShortcutBinding {
id: id.clone(),
binding: Binding::default(),
input: String::new(),
editing: true,
is_default: false,
});
return widget::text_input::focus(id);
}
}
}
ShortcutMessage::ApplyReplace => {
if let Some((id, new_binding, ..)) = self.replace_dialog.take() {
if let Some(short_id) = self.shortcut_context {
// Remove conflicting bindings that are saved on disk.
self.config_remove(&new_binding);
// Clear any binding that matches this in the current model
for (_, model) in &mut self.shortcut_models {
if let Some(id) = model
.bindings
.iter()
.find(|(_, shortcut)| shortcut.binding == new_binding)
.map(|(id, _)| id)
{
model.bindings.remove(id);
break;
}
}
// Update the current model and save the binding to disk.
if let Some(model) = self.shortcut_models.get_mut(short_id) {
if let Some(shortcut) = model.bindings.get_mut(id) {
let prev_binding = shortcut.binding.clone();
shortcut.binding = new_binding.clone();
shortcut.input.clear();
shortcut.editing = false;
let action = model.action.clone();
self.config_remove(&prev_binding);
self.config_add(action, new_binding);
}
}
self.on_enter();
}
}
}
ShortcutMessage::CancelReplace => self.replace_dialog = None,
ShortcutMessage::DeleteBinding(id) => {
if let Some(short_id) = self.shortcut_context {
if let Some(model) = self.shortcut_models.get_mut(short_id) {
let shortcut = model.bindings.remove(id);
if shortcut.is_default {
self.config_add(Action::Disable, shortcut.binding.clone());
} else {
// if last keybind deleted, clear shortcut context
if model.bindings.is_empty() {
self.shortcut_context = None;
}
self.config_remove(&shortcut.binding);
}
self.on_enter();
}
}
}
ShortcutMessage::DeleteShortcut(id) => {
let model = self.shortcut_models.remove(id);
for (_, shortcut) in model.bindings {
self.config_remove(&shortcut.binding);
self.on_enter();
}
}
ShortcutMessage::EditBinding(id, enable) => {
if let Some(short_id) = self.shortcut_context {
if let Some(model) = self.shortcut_models.get_mut(short_id) {
if let Some(shortcut) = model.bindings.get_mut(id) {
shortcut.editing = enable;
if enable {
shortcut.input = shortcut.binding.to_string();
return widget::text_input::select_all(shortcut.id.clone());
}
}
}
}
}
ShortcutMessage::InputBinding(id, text) => {
if let Some(short_id) = self.shortcut_context {
if let Some(model) = self.shortcut_models.get_mut(short_id) {
if let Some(shortcut) = model.bindings.get_mut(id) {
shortcut.input = text;
}
}
}
}
// Removes all bindings from the active shortcut context, and reloads the shortcuts model.
ShortcutMessage::ResetBindings => {
if let Some(short_id) = self.shortcut_context {
if let Some(model) = self.shortcut_models.get(short_id) {
for (_, shortcut) in &model.bindings {
self.config_remove(&shortcut.binding);
}
if let Ok(defaults) = self.config.get::<Shortcuts>("defaults") {
for (binding, action) in defaults.0 {
if action == model.action {
self.config_remove(&binding);
}
}
}
}
self.on_enter();
}
}
ShortcutMessage::ShowShortcut(id, description) => {
self.shortcut_context = Some(id);
self.replace_dialog = None;
let mut tasks = vec![cosmic::command::message(
crate::app::Message::OpenContextDrawer(self.entity, description.into()),
)];
if let Some(model) = self.shortcut_models.get(0) {
if let Some(shortcut) = model.bindings.get(0) {
tasks.push(widget::text_input::focus(shortcut.id.clone()));
tasks.push(widget::text_input::select_all(shortcut.id.clone()));
}
}
return Task::batch(tasks);
}
ShortcutMessage::SubmitBinding(id) => {
if let Some(short_id) = self.shortcut_context {
let mut apply_binding = None;
// Check for conflicts with the new binding.
if let Some(model) = self.shortcut_models.get_mut(short_id) {
if let Some(shortcut) = model.bindings.get_mut(id) {
match Binding::from_str(&shortcut.input) {
Ok(new_binding) => {
if !new_binding.is_set() {
shortcut.input.clear();
return Task::none();
}
if let Some(action) = self.config_contains(&new_binding) {
let action_str = if let Action::Spawn(_) = &action {
super::localize_custom_action(&action, &new_binding)
} else {
super::localize_action(&action)
};
self.replace_dialog =
Some((id, new_binding, action, action_str));
return Task::none();
}
apply_binding = Some(new_binding);
}
Err(why) => {
tracing::error!(why, "keybinding input invalid");
}
}
}
}
// Apply if no conflict was found.
if let Some(new_binding) = apply_binding {
if let Some(model) = self.shortcut_models.get_mut(short_id) {
if let Some(shortcut) = model.bindings.get_mut(id) {
let prev_binding = shortcut.binding.clone();
shortcut.binding = new_binding.clone();
shortcut.input.clear();
shortcut.editing = false;
let action = model.action.clone();
self.config_remove(&prev_binding);
self.config_add(action, new_binding);
self.on_enter();
}
}
}
}
}
}
Task::none()
}
pub(super) fn view(&self) -> Element<ShortcutMessage> {
self.shortcut_models
.iter()
.map(|(id, shortcut)| shortcut_item(self.custom, id, shortcut))
.fold(widget::list_column(), widget::ListColumn::add)
.into()
}
}
fn context_drawer(
shortcuts: &Slab<ShortcutModel>,
id: usize,
show_action: bool,
) -> Element<ShortcutMessage> {
let cosmic::cosmic_theme::Spacing {
space_xxs,
space_xs,
space_l,
..
} = theme::active().cosmic().spacing;
let model = &shortcuts[id];
let action = show_action.then(|| {
let description = if let Action::Spawn(task) = &model.action {
Cow::Borrowed(task.as_str())
} else {
Cow::Owned(super::localize_action(&model.action))
};
text::body(description)
});
let bindings = model.bindings.iter().enumerate().fold(
widget::list_column().spacing(space_xxs),
|section, (_, (bind_id, shortcut))| {
let text: Cow<'_, str> = if !shortcut.editing && shortcut.binding.is_set() {
Cow::Owned(shortcut.binding.to_string())
} else {
Cow::Borrowed(&shortcut.input)
};
let input = widget::editable_input("", text, shortcut.editing, move |enable| {
ShortcutMessage::EditBinding(bind_id, enable)
})
.select_on_focus(true)
.on_input(move |text| ShortcutMessage::InputBinding(bind_id, text))
.on_submit(ShortcutMessage::SubmitBinding(bind_id))
.padding([0, space_xs])
.id(shortcut.id.clone())
.into();
let delete_button = widget::button::icon(icon::from_name("edit-delete-symbolic"))
.on_press(ShortcutMessage::DeleteBinding(bind_id))
.into();
let flex_control =
settings::flex_item_row(vec![input, delete_button]).align_items(Alignment::Center);
section.add(flex_control)
},
);
// TODO: Detect when it is necessary
let reset_keybinding_button = if show_action {
None
} else {
let button = widget::button::standard(fl!("reset-to-default"))
.on_press(ShortcutMessage::ResetBindings);
Some(button)
};
let add_keybinding_button =
widget::button::standard(fl!("add-keybinding")).on_press(ShortcutMessage::AddKeybinding);
let button_container = widget::row::with_capacity(2)
.push_maybe(reset_keybinding_button)
.push(add_keybinding_button)
.spacing(space_xs)
.apply(widget::container)
.width(Length::Fill)
.align_x(Horizontal::Right);
widget::column::with_capacity(if show_action { 3 } else { 2 })
.spacing(space_l)
.push_maybe(action)
.push(bindings)
.push(button_container)
.into()
}
/// Display a shortcut as a list item
fn shortcut_item(custom: bool, id: usize, data: &ShortcutModel) -> Element<ShortcutMessage> {
#[derive(Copy, Clone, Debug)]
enum LocalMessage {
Remove,
Show,
}
let bindings = data
.bindings
.iter()
.take(3)
.filter(|(_, shortcut)| shortcut.binding.is_set())
.map(|(_, shortcut)| text::body(shortcut.binding.to_string()).into())
.collect::<Vec<_>>();
let shortcuts: Element<LocalMessage> = if bindings.is_empty() {
text::body(fl!("disabled")).into()
} else {
widget::column::with_children(bindings)
.align_x(Alignment::End)
.into()
};
let modified = if data.modified == 0 {
None
} else {
Some(text::body(fl!("modified", count = data.modified)))
};
let control = widget::row::with_capacity(4)
.push_maybe(modified)
.push(shortcuts)
.push(icon::from_name("go-next-symbolic").size(16))
.push_maybe(custom.then(|| {
widget::button::icon(icon::from_name("edit-delete-symbolic"))
.on_press(LocalMessage::Remove)
}))
.align_y(Alignment::Center)
.spacing(8);
settings::item::builder(&data.description)
.flex_control(control)
.spacing(16)
.apply(widget::container)
.class(theme::Container::List)
.apply(widget::button::custom)
.class(theme::Button::Transparent)
.on_press(LocalMessage::Show)
.apply(Element::from)
.map(move |message| match message {
LocalMessage::Show => ShortcutMessage::ShowShortcut(id, data.description.clone()),
LocalMessage::Remove => ShortcutMessage::DeleteShortcut(id),
})
}