Skip to content

Commit 390cc71

Browse files
authored
Merge pull request #2109 from luca020400/luca/keybind
Duplicate key bind validation
2 parents 6510de7 + c3ede6e commit 390cc71

4 files changed

Lines changed: 173 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Added:
77
- Config option (`preview.card.description_decode_html`) to decode html strings in preview card descriptions
88
- Support displaying a larger version of the card images in-app
99
- IRCv3 `no-implicit-names` support
10+
- Keyboard shortcuts validation (e.g no duplicate key binds)
1011

1112
Fixed:
1213

data/src/config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ use crate::serde::{
4040
deserialize_f32_positive_float_maybe, deserialize_u8_positive_integer_maybe,
4141
};
4242
use crate::server::{ConfigMap as ServerMap, ServerName};
43+
use crate::shortcut::{Commands, KeyBind};
4344
use crate::{Theme, environment};
4445

4546
pub mod actions;
@@ -561,6 +562,8 @@ impl Config {
561562
})
562563
.map_err(|e| Error::Parse(e.to_string()))?;
563564

565+
keyboard.validate()?;
566+
564567
let servers = ServerMap::new(
565568
servers,
566569
sidebar.order_channels_by,
@@ -815,6 +818,8 @@ pub enum Error {
815818
"Exactly one of sasl.plain.password, sasl.plain.password_file or sasl.plain.password_command must be set."
816819
)]
817820
DuplicateSaslPassword,
821+
#[error("Keybind \"{}\" is assigned to multiple actions: {}", keybind.as_config_string(), actions.as_config_string())]
822+
KeyBindConflict { keybind: KeyBind, actions: Commands },
818823
#[error("Config does not exist")]
819824
ConfigMissing,
820825
}

data/src/config/keys.rs

Lines changed: 73 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
use std::collections::HashMap;
2+
13
use serde::Deserialize;
24

3-
use crate::shortcut::{KeyBind, KeyBinds, Shortcut, shortcut};
5+
use crate::config::Error;
6+
use crate::shortcut::{
7+
Command, Commands, KeyBind, KeyBinds, Shortcut, shortcut,
8+
};
49

510
#[derive(Debug, Clone, Deserialize)]
611
#[serde(default)]
@@ -81,55 +86,76 @@ impl Default for Keyboard {
8186
}
8287

8388
impl Keyboard {
84-
pub fn shortcuts(&self) -> Vec<Shortcut> {
85-
use crate::shortcut::Command::*;
89+
fn keybind_pairs(&self) -> Vec<(&KeyBinds, Command)> {
90+
use Command::*;
91+
vec![
92+
(&self.move_up, MoveUp),
93+
(&self.move_down, MoveDown),
94+
(&self.move_left, MoveLeft),
95+
(&self.move_right, MoveRight),
96+
(&self.new_horizontal_buffer, NewHorizontalBuffer),
97+
(&self.new_vertical_buffer, NewVerticalBuffer),
98+
(&self.close_buffer, CloseBuffer),
99+
(&self.maximize_buffer, MaximizeBuffer),
100+
(&self.restore_buffer, RestoreBuffer),
101+
(&self.cycle_next_buffer, CycleNextBuffer),
102+
(&self.cycle_previous_buffer, CyclePreviousBuffer),
103+
(&self.leave_buffer, LeaveBuffer),
104+
(&self.toggle_nick_list, ToggleNicklist),
105+
(&self.toggle_topic, ToggleTopic),
106+
(&self.toggle_sidebar, ToggleSidebar),
107+
(&self.toggle_fullscreen, ToggleFullscreen),
108+
(&self.command_bar, CommandBar),
109+
(&self.reload_configuration, ReloadConfiguration),
110+
(&self.file_transfers, FileTransfers),
111+
(&self.logs, Logs),
112+
(&self.theme_editor, ThemeEditor),
113+
(&self.scroll_up_page, ScrollUpPage),
114+
(&self.scroll_down_page, ScrollDownPage),
115+
(&self.scroll_to_top, ScrollToTop),
116+
(&self.scroll_to_bottom, ScrollToBottom),
117+
(&self.highlights, Highlights),
118+
(&self.cycle_next_unread_buffer, CycleNextUnreadBuffer),
119+
(
120+
&self.cycle_previous_unread_buffer,
121+
CyclePreviousUnreadBuffer,
122+
),
123+
(&self.mark_as_read, MarkAsRead),
124+
(&self.quit_application, QuitApplication),
125+
(&self.open_config_file, OpenConfigFile),
126+
]
127+
}
86128

87-
let mut shortcuts = vec![];
129+
pub fn validate(&self) -> Result<(), Error> {
130+
let mut map: HashMap<KeyBind, Vec<Command>> = HashMap::new();
88131

89-
let mut push = |key_binds: &KeyBinds, command| {
90-
shortcuts.extend(
91-
key_binds
92-
.iter()
93-
.cloned()
94-
.map(|key_bind| shortcut(key_bind, command)),
95-
);
96-
};
132+
for (keybinds, command) in self.keybind_pairs() {
133+
for key in keybinds.iter() {
134+
map.entry(key.clone()).or_default().push(command);
135+
}
136+
}
97137

98-
push(&self.move_up, MoveUp);
99-
push(&self.move_down, MoveDown);
100-
push(&self.move_left, MoveLeft);
101-
push(&self.move_right, MoveRight);
102-
push(&self.new_horizontal_buffer, NewHorizontalBuffer);
103-
push(&self.new_vertical_buffer, NewVerticalBuffer);
104-
push(&self.close_buffer, CloseBuffer);
105-
push(&self.maximize_buffer, MaximizeBuffer);
106-
push(&self.restore_buffer, RestoreBuffer);
107-
push(&self.cycle_next_buffer, CycleNextBuffer);
108-
push(&self.cycle_previous_buffer, CyclePreviousBuffer);
109-
push(&self.leave_buffer, LeaveBuffer);
110-
push(&self.toggle_nick_list, ToggleNicklist);
111-
push(&self.toggle_topic, ToggleTopic);
112-
push(&self.toggle_sidebar, ToggleSidebar);
113-
push(&self.toggle_fullscreen, ToggleFullscreen);
114-
push(&self.command_bar, CommandBar);
115-
push(&self.reload_configuration, ReloadConfiguration);
116-
push(&self.file_transfers, FileTransfers);
117-
push(&self.logs, Logs);
118-
push(&self.theme_editor, ThemeEditor);
119-
push(&self.scroll_up_page, ScrollUpPage);
120-
push(&self.scroll_down_page, ScrollDownPage);
121-
push(&self.scroll_to_top, ScrollToTop);
122-
push(&self.scroll_to_bottom, ScrollToBottom);
123-
push(&self.highlights, Highlights);
124-
push(&self.cycle_next_unread_buffer, CycleNextUnreadBuffer);
125-
push(
126-
&self.cycle_previous_unread_buffer,
127-
CyclePreviousUnreadBuffer,
128-
);
129-
push(&self.mark_as_read, MarkAsRead);
130-
push(&self.quit_application, QuitApplication);
131-
push(&self.open_config_file, OpenConfigFile);
138+
for (key, commands) in map {
139+
if commands.len() > 1 {
140+
return Err(Error::KeyBindConflict {
141+
keybind: key,
142+
actions: Commands::from(commands),
143+
});
144+
}
145+
}
146+
147+
Ok(())
148+
}
132149

133-
shortcuts
150+
pub fn shortcuts(&self) -> Vec<Shortcut> {
151+
self.keybind_pairs()
152+
.into_iter()
153+
.flat_map(|(keybinds, command)| {
154+
keybinds
155+
.iter()
156+
.cloned()
157+
.map(move |key_bind| shortcut(key_bind, command))
158+
})
159+
.collect()
134160
}
135161
}

data/src/shortcut.rs

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ impl<'de> Deserialize<'de> for KeyBinds {
7575
}
7676
}
7777

78-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78+
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)]
79+
#[strum(serialize_all = "snake_case")]
7980
pub enum Command {
8081
MoveUp,
8182
MoveDown,
@@ -148,6 +149,46 @@ macro_rules! default {
148149
};
149150
}
150151

152+
#[derive(Debug, Clone, Default)]
153+
pub struct Commands(Vec<Command>);
154+
155+
impl From<Vec<Command>> for Commands {
156+
fn from(commands: Vec<Command>) -> Self {
157+
Self(commands)
158+
}
159+
}
160+
161+
impl Commands {
162+
pub fn as_config_string(&self) -> String {
163+
if let Some((last, rest)) = self.0.split_last() {
164+
if self.0.len() == 1 {
165+
format!("{last}")
166+
} else {
167+
let mut config_string = String::new();
168+
169+
if self.0.len() == 2 {
170+
if let Some(command) = rest.first() {
171+
config_string
172+
.push_str(format!("{command} and ").as_str());
173+
}
174+
175+
config_string.push_str(format!("{last}").as_str());
176+
} else {
177+
for command in rest {
178+
config_string.push_str(format!("{command}, ").as_str());
179+
}
180+
181+
config_string.push_str(format!("and {last}").as_str());
182+
}
183+
184+
config_string
185+
}
186+
} else {
187+
String::new()
188+
}
189+
}
190+
}
191+
151192
#[derive(Debug, Clone, Eq, Ord, PartialOrd)]
152193
pub enum KeyBind {
153194
Bind {
@@ -169,6 +210,22 @@ impl fmt::Display for KeyBind {
169210
}
170211
}
171212

213+
impl KeyBind {
214+
pub fn as_config_string(&self) -> String {
215+
match self {
216+
KeyBind::Bind {
217+
key_code,
218+
modifiers,
219+
} => format!(
220+
"{}+{}",
221+
modifiers.as_config_string(),
222+
key_code.to_string().to_lowercase()
223+
),
224+
KeyBind::Unbind => String::new(),
225+
}
226+
}
227+
}
228+
172229
impl PartialEq for KeyBind {
173230
fn eq(&self, other: &Self) -> bool {
174231
match (self, other) {
@@ -210,8 +267,12 @@ impl Hash for KeyBind {
210267
key_code,
211268
modifiers,
212269
} => {
213-
key_code.hash(state);
214270
modifiers.hash(state);
271+
if let keyboard::Key::Character(c) = &key_code.0 {
272+
keyboard::Key::Character(c.to_lowercase()).hash(state);
273+
} else {
274+
key_code.hash(state);
275+
}
215276
}
216277
KeyBind::Unbind => {
217278
std::mem::discriminant(self).hash(state);
@@ -420,6 +481,37 @@ impl fmt::Display for Modifiers {
420481
}
421482
}
422483

484+
impl Modifiers {
485+
fn as_config_string(&self) -> String {
486+
let mut mods = vec![];
487+
let inner = self.0;
488+
489+
if inner.contains(keyboard::Modifiers::SHIFT) {
490+
mods.push("shift");
491+
}
492+
if inner.contains(keyboard::Modifiers::CTRL) {
493+
mods.push("ctrl");
494+
}
495+
if inner.contains(keyboard::Modifiers::ALT) {
496+
if cfg!(target_os = "macos") {
497+
mods.push("opt");
498+
} else {
499+
mods.push("alt");
500+
}
501+
}
502+
if inner.contains(keyboard::Modifiers::LOGO) {
503+
if cfg!(target_os = "macos") {
504+
} else if cfg!(target_os = "windows") {
505+
mods.push("win");
506+
} else {
507+
mods.push("super");
508+
}
509+
}
510+
511+
mods.join("+")
512+
}
513+
}
514+
423515
impl fmt::Display for KeyCode {
424516
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425517
let key = match self.0.clone() {

0 commit comments

Comments
 (0)