oxwm

https://git.tonybtw.com/oxwm.git git://git.tonybtw.com/oxwm.git

keychords

Commit
2809716423b958b733761c1af4e4b681c077729a
Parent
c46b018
Author
tonybtw <tonybtw@tonybtw.com>
Date
2025-10-28 15:21:27

Diff

diff --git a/src/bar/bar.rs b/src/bar/bar.rs
index f45e59c..567e3cb 100644
--- a/src/bar/bar.rs
+++ b/src/bar/bar.rs
@@ -172,6 +172,7 @@ impl Bar {
         occupied_tags: u32,
         draw_blocks: bool,
         layout_symbol: &str,
+        keychord_indicator: Option<&str>,
     ) -> Result<(), X11Error> {
         if !self.needs_redraw {
             return Ok(());
@@ -261,6 +262,25 @@ impl Bar {
             layout_symbol
         );
 
+        // Update x_position after layout symbol
+        x_position += font.text_width(layout_symbol) as i16;
+
+        // Draw keychord indicator if present
+        if let Some(indicator) = keychord_indicator {
+            x_position += 10;
+
+            let text_x = x_position;
+            let text_y = top_padding + font.ascent();
+
+            self.font_draw.draw_text(
+                font,
+                self.scheme_selected.foreground,
+                text_x,
+                text_y,
+                indicator,
+            );
+        }
+
         if draw_blocks && !self.status_text.is_empty() {
             let padding = 10;
             let mut x_position = self.width as i16 - padding;
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 2fc4649..ee93417 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -1,6 +1,6 @@
 use crate::bar::{BlockCommand, BlockConfig};
 use crate::errors::ConfigError;
-use crate::keyboard::handlers::Key;
+use crate::keyboard::handlers::{KeyBinding, KeyPress};
 use crate::keyboard::keycodes;
 use crate::keyboard::{Arg, KeyAction};
 use serde::Deserialize;
@@ -8,6 +8,7 @@ use x11rb::protocol::xproto::{KeyButMask, Keycode};
 
 #[derive(Debug, Deserialize)]
 pub enum ModKey {
+    Mod,
     Mod1,
     Mod2,
     Mod3,
@@ -20,6 +21,7 @@ pub enum ModKey {
 impl ModKey {
     fn to_keybut_mask(&self) -> KeyButMask {
         match self {
+            ModKey::Mod => panic!("ModKey::Mod should be replaced during config parsing"),
             ModKey::Mod1 => KeyButMask::MOD1,
             ModKey::Mod2 => KeyButMask::MOD2,
             ModKey::Mod3 => KeyButMask::MOD3,
@@ -196,13 +198,23 @@ struct ConfigData {
 
 #[derive(Debug, Deserialize)]
 struct KeybindingData {
-    modifiers: Vec<ModKey>,
-    key: KeyData,
+    #[serde(default)]
+    keys: Option<Vec<KeyPressData>>,  // New format
+    #[serde(default)]
+    modifiers: Option<Vec<ModKey>>,   // Old format (backwards compat)
+    #[serde(default)]
+    key: Option<KeyData>,              // Old format (backwards compat)
     action: KeyAction,
     #[serde(default)]
     arg: ArgData,
 }
 
+#[derive(Debug, Deserialize)]
+struct KeyPressData {
+    modifiers: Vec<ModKey>,
+    key: KeyData,
+}
+
 #[derive(Debug, Deserialize)]
 #[serde(untagged)]
 enum ArgData {
@@ -250,17 +262,46 @@ fn config_data_to_config(data: ConfigData) -> Result<crate::Config, ConfigError>
 
     let mut keybindings = Vec::new();
     for kb_data in data.keybindings {
-        let modifiers = kb_data
-            .modifiers
-            .iter()
-            .map(|m| m.to_keybut_mask())
-            .collect();
+        let keys = if let Some(keys_data) = kb_data.keys {
+            // New format: multiple keys
+            keys_data
+                .into_iter()
+                .map(|kp| {
+                    let modifiers = kp
+                        .modifiers
+                        .iter()
+                        .map(|m| match m {
+                            ModKey::Mod => modkey,
+                            _ => m.to_keybut_mask(),
+                        })
+                        .collect();
+
+                    KeyPress {
+                        modifiers,
+                        key: kp.key.to_keycode(),
+                    }
+                })
+                .collect()
+        } else if let (Some(modifiers), Some(key)) = (kb_data.modifiers, kb_data.key) {
+            // Old format: single key (backwards compatibility)
+            vec![KeyPress {
+                modifiers: modifiers
+                    .iter()
+                    .map(|m| match m {
+                        ModKey::Mod => modkey,
+                        _ => m.to_keybut_mask(),
+                    })
+                    .collect(),
+                key: key.to_keycode(),
+            }]
+        } else {
+            return Err(ConfigError::ValidationError("Keybinding must have either 'keys' or 'modifiers'+'key'".to_string()));
+        };
 
-        let key = kb_data.key.to_keycode();
         let action = kb_data.action;
         let arg = arg_data_to_arg(kb_data.arg)?;
 
-        keybindings.push(Key::new(modifiers, key, action, arg));
+        keybindings.push(KeyBinding::new(keys, action, arg));
     }
 
     let mut status_blocks = Vec::new();
diff --git a/src/errors.rs b/src/errors.rs
index f698f07..9555d90 100644
--- a/src/errors.rs
+++ b/src/errors.rs
@@ -25,6 +25,7 @@ pub enum ConfigError {
     UnknownAction(String),
     UnknownBlockCommand(String),
     MissingCommandArg { command: String, field: String },
+    ValidationError(String),
 }
 
 impl std::fmt::Display for WmError {
@@ -67,6 +68,7 @@ impl std::fmt::Display for ConfigError {
             Self::MissingCommandArg { command, field } => {
                 write!(f, "{} command requires {}", command, field)
             }
+            Self::ValidationError(msg) => write!(f, "Config validation error: {}", msg),
         }
     }
 }
diff --git a/src/keyboard/handlers.rs b/src/keyboard/handlers.rs
index 15df5de..c7722c9 100644
--- a/src/keyboard/handlers.rs
+++ b/src/keyboard/handlers.rs
@@ -1,4 +1,6 @@
+use std::collections::HashSet;
 use std::io;
+use std::io::ErrorKind;
 use std::process::Command;
 
 use serde::Deserialize;
@@ -6,6 +8,7 @@ use x11rb::connection::Connection;
 use x11rb::protocol::xproto::*;
 
 use crate::errors::X11Error;
+use crate::keyboard::keycodes;
 
 #[derive(Debug, Copy, Clone, Deserialize)]
 pub enum KeyAction {
@@ -42,26 +45,58 @@ impl Arg {
     }
 }
 
-#[derive(Clone)]
-pub struct Key {
+// Individual key press in a sequence
+#[derive(Clone, Debug)]
+pub struct KeyPress {
     pub(crate) modifiers: Vec<KeyButMask>,
     pub(crate) key: Keycode,
+}
+
+// Keybinding that can be a single key or a chord (sequence of keys)
+#[derive(Clone)]
+pub struct KeyBinding {
+    pub(crate) keys: Vec<KeyPress>,
     pub(crate) func: KeyAction,
     pub(crate) arg: Arg,
 }
 
-impl Key {
-    pub fn new(modifiers: Vec<KeyButMask>, key: Keycode, func: KeyAction, arg: Arg) -> Self {
+impl KeyBinding {
+    pub fn new(keys: Vec<KeyPress>, func: KeyAction, arg: Arg) -> Self {
+        Self { keys, func, arg }
+    }
+
+    // Helper for backwards compatibility with single-key bindings
+    pub fn single_key(modifiers: Vec<KeyButMask>, key: Keycode, func: KeyAction, arg: Arg) -> Self {
         Self {
-            modifiers,
-            key,
+            keys: vec![KeyPress { modifiers, key }],
             func,
             arg,
         }
     }
 }
 
-fn modifiers_to_mask(modifiers: &[KeyButMask]) -> u16 {
+// For backwards compatibility during migration
+pub type Key = KeyBinding;
+
+#[derive(Debug, Clone)]
+pub enum KeychordState {
+    Idle,
+    InProgress {
+        // Indices of keybindings that could still match
+        candidates: Vec<usize>,
+        // How many keys have been pressed in the sequence
+        keys_pressed: usize,
+    },
+}
+
+pub enum KeychordResult {
+    Completed(KeyAction, Arg),
+    InProgress(Vec<usize>),
+    None,
+    Cancelled,
+}
+
+pub fn modifiers_to_mask(modifiers: &[KeyButMask]) -> u16 {
     modifiers
         .iter()
         .fold(0u16, |acc, &modifier| acc | u16::from(modifier))
@@ -70,47 +105,153 @@ fn modifiers_to_mask(modifiers: &[KeyButMask]) -> u16 {
 pub fn setup_keybinds(
     connection: &impl Connection,
     root: Window,
-    keybindings: &[Key],
+    keybindings: &[KeyBinding],
 ) -> Result<(), X11Error> {
+    let mut grabbed_keys: HashSet<(u16, Keycode)> = HashSet::new();
+
     for keybinding in keybindings {
-        let modifier_mask = modifiers_to_mask(&keybinding.modifiers);
-
-        connection.grab_key(
-            false,
-            root,
-            modifier_mask.into(),
-            keybinding.key,
-            GrabMode::ASYNC,
-            GrabMode::ASYNC,
-        )?;
+        if keybinding.keys.is_empty() {
+            continue;
+        }
+
+        let first_key = &keybinding.keys[0];
+        let modifier_mask = modifiers_to_mask(&first_key.modifiers);
+        let key_tuple = (modifier_mask, first_key.key);
+
+        if grabbed_keys.insert(key_tuple) {
+            connection.grab_key(
+                false,
+                root,
+                modifier_mask.into(),
+                first_key.key,
+                GrabMode::ASYNC,
+                GrabMode::ASYNC,
+            )?;
+        }
     }
+
+    connection.grab_key(
+        false,
+        root,
+        ModMask::from(0u16),
+        keycodes::ESCAPE,
+        GrabMode::ASYNC,
+        GrabMode::ASYNC,
+    )?;
+
     Ok(())
 }
 
-pub fn handle_key_press(event: KeyPressEvent, keybindings: &[Key]) -> (KeyAction, Arg) {
-    for keybinding in keybindings {
-        let modifier_mask = modifiers_to_mask(&keybinding.modifiers);
+pub fn handle_key_press(
+    event: KeyPressEvent,
+    keybindings: &[KeyBinding],
+    keychord_state: &KeychordState,
+) -> KeychordResult {
+    if event.detail == keycodes::ESCAPE {
+        return match keychord_state {
+            KeychordState::InProgress { .. } => KeychordResult::Cancelled,
+            KeychordState::Idle => KeychordResult::None,
+        };
+    }
+
+    match keychord_state {
+        KeychordState::Idle => {
+            handle_first_key(event, keybindings)
+        }
+        KeychordState::InProgress { candidates, keys_pressed } => {
+            handle_next_key(event, keybindings, candidates, *keys_pressed)
+        }
+    }
+}
+
+fn handle_first_key(
+    event: KeyPressEvent,
+    keybindings: &[KeyBinding],
+) -> KeychordResult {
+    let mut candidates = Vec::new();
+
+    for (keybinding_index, keybinding) in keybindings.iter().enumerate() {
+        if keybinding.keys.is_empty() {
+            continue;
+        }
+
+        let first_key = &keybinding.keys[0];
+        let modifier_mask = modifiers_to_mask(&first_key.modifiers);
 
-        if event.detail == keybinding.key && event.state == modifier_mask.into() {
-            return (keybinding.func, keybinding.arg.clone());
+        if event.detail == first_key.key && event.state == modifier_mask.into() {
+            if keybinding.keys.len() == 1 {
+                return KeychordResult::Completed(
+                    keybinding.func,
+                    keybinding.arg.clone(),
+                );
+            } else {
+                candidates.push(keybinding_index);
+            }
         }
     }
 
-    (KeyAction::None, Arg::None)
+    if candidates.is_empty() {
+        KeychordResult::None
+    } else {
+        KeychordResult::InProgress(candidates)
+    }
+}
+
+fn handle_next_key(
+    event: KeyPressEvent,
+    keybindings: &[KeyBinding],
+    candidates: &[usize],
+    keys_pressed: usize,
+) -> KeychordResult {
+    let mut new_candidates = Vec::new();
+
+    for &candidate_index in candidates {
+        let keybinding = &keybindings[candidate_index];
+
+        if keys_pressed >= keybinding.keys.len() {
+            continue;
+        }
+
+        let next_key = &keybinding.keys[keys_pressed];
+        let required_mask = modifiers_to_mask(&next_key.modifiers);
+        let event_state: u16 = event.state.into();
+
+        let modifiers_match = if next_key.modifiers.is_empty() {
+            true
+        } else {
+            (event_state & required_mask) == required_mask
+        };
+
+        if event.detail == next_key.key && modifiers_match {
+            if keys_pressed + 1 == keybinding.keys.len() {
+                return KeychordResult::Completed(
+                    keybinding.func,
+                    keybinding.arg.clone(),
+                );
+            } else {
+                new_candidates.push(candidate_index);
+            }
+        }
+    }
+
+    if new_candidates.is_empty() {
+        KeychordResult::Cancelled
+    } else {
+        KeychordResult::InProgress(new_candidates)
+    }
 }
 
 pub fn handle_spawn_action(action: KeyAction, arg: &Arg) -> io::Result<()> {
-    use io::ErrorKind;
     if let KeyAction::Spawn = action {
         match arg {
             Arg::Str(command) => match Command::new(command.as_str()).spawn() {
-                Err(err) if err.kind() == ErrorKind::NotFound => {
+                Err(error) if error.kind() == ErrorKind::NotFound => {
                     eprintln!(
                         "KeyAction::Spawn failed: could not spawn \"{}\", command not found",
                         command
                     );
                 }
-                Err(err) => Err(err)?,
+                Err(error) => Err(error)?,
                 _ => (),
             },
             Arg::Array(command) => {
@@ -120,13 +261,13 @@ pub fn handle_spawn_action(action: KeyAction, arg: &Arg) -> io::Result<()> {
 
                 let args_str: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
                 match Command::new(cmd.as_str()).args(&args_str).spawn() {
-                    Err(err) if err.kind() == ErrorKind::NotFound => {
+                    Err(error) if error.kind() == ErrorKind::NotFound => {
                         eprintln!(
                             "KeyAction::Spawn failed: could not spawn \"{}\", command not found",
                             cmd
                         );
                     }
-                    Err(err) => Err(err)?,
+                    Err(error) => Err(error)?,
                     _ => (),
                 }
             }
diff --git a/src/lib.rs b/src/lib.rs
index 147cdb6..75da74b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -10,7 +10,7 @@ pub mod prelude {
     pub use crate::ColorScheme;
     pub use crate::LayoutSymbolOverride;
     pub use crate::bar::{BlockCommand, BlockConfig};
-    pub use crate::keyboard::{Arg, KeyAction, handlers::Key, keycodes};
+    pub use crate::keyboard::{Arg, KeyAction, handlers::KeyBinding, keycodes};
     pub use x11rb::protocol::xproto::KeyButMask;
 }
 
@@ -66,7 +66,7 @@ pub struct ColorScheme {
 
 impl Default for Config {
     fn default() -> Self {
-        use crate::keyboard::handlers::Key;
+        use crate::keyboard::handlers::KeyBinding;
         use crate::keyboard::{Arg, KeyAction, keycodes};
         use x11rb::protocol::xproto::KeyButMask;
 
@@ -93,13 +93,13 @@ impl Default for Config {
                 .collect(),
             layout_symbols: vec![],
             keybindings: vec![
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::RETURN,
                     KeyAction::Spawn,
                     Arg::Str(TERMINAL.to_string()),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::D,
                     KeyAction::Spawn,
@@ -109,167 +109,167 @@ impl Default for Config {
                         "dmenu_run -l 10".to_string(),
                     ]),
                 ),
-                Key::new(vec![MODKEY], keycodes::Q, KeyAction::KillClient, Arg::None),
-                Key::new(vec![MODKEY], keycodes::N, KeyAction::CycleLayout, Arg::None),
-                Key::new(
+                KeyBinding::single_key(vec![MODKEY], keycodes::Q, KeyAction::KillClient, Arg::None),
+                KeyBinding::single_key(vec![MODKEY], keycodes::N, KeyAction::CycleLayout, Arg::None),
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::F,
                     KeyAction::ToggleFullScreen,
                     Arg::None,
                 ),
-                Key::new(vec![MODKEY], keycodes::A, KeyAction::ToggleGaps, Arg::None),
-                Key::new(vec![MODKEY, SHIFT], keycodes::Q, KeyAction::Quit, Arg::None),
-                Key::new(
+                KeyBinding::single_key(vec![MODKEY], keycodes::A, KeyAction::ToggleGaps, Arg::None),
+                KeyBinding::single_key(vec![MODKEY, SHIFT], keycodes::Q, KeyAction::Quit, Arg::None),
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::R,
                     KeyAction::Restart,
                     Arg::None,
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::F,
                     KeyAction::ToggleFloating,
                     Arg::None,
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::J,
                     KeyAction::FocusStack,
                     Arg::Int(-1),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::K,
                     KeyAction::FocusStack,
                     Arg::Int(1),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::K,
                     KeyAction::ExchangeClient,
                     Arg::Int(0), // UP
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::J,
                     KeyAction::ExchangeClient,
                     Arg::Int(1), // DOWN
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::H,
                     KeyAction::ExchangeClient,
                     Arg::Int(2), // LEFT
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::L,
                     KeyAction::ExchangeClient,
                     Arg::Int(3), // RIGHT
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::KEY_1,
                     KeyAction::ViewTag,
                     Arg::Int(0),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::KEY_2,
                     KeyAction::ViewTag,
                     Arg::Int(1),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::KEY_3,
                     KeyAction::ViewTag,
                     Arg::Int(2),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::KEY_4,
                     KeyAction::ViewTag,
                     Arg::Int(3),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::KEY_5,
                     KeyAction::ViewTag,
                     Arg::Int(4),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::KEY_6,
                     KeyAction::ViewTag,
                     Arg::Int(5),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::KEY_7,
                     KeyAction::ViewTag,
                     Arg::Int(6),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::KEY_8,
                     KeyAction::ViewTag,
                     Arg::Int(7),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY],
                     keycodes::KEY_9,
                     KeyAction::ViewTag,
                     Arg::Int(8),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::KEY_1,
                     KeyAction::MoveToTag,
                     Arg::Int(0),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::KEY_2,
                     KeyAction::MoveToTag,
                     Arg::Int(1),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::KEY_3,
                     KeyAction::MoveToTag,
                     Arg::Int(2),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::KEY_4,
                     KeyAction::MoveToTag,
                     Arg::Int(3),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::KEY_5,
                     KeyAction::MoveToTag,
                     Arg::Int(4),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::KEY_6,
                     KeyAction::MoveToTag,
                     Arg::Int(5),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::KEY_7,
                     KeyAction::MoveToTag,
                     Arg::Int(6),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::KEY_8,
                     KeyAction::MoveToTag,
                     Arg::Int(7),
                 ),
-                Key::new(
+                KeyBinding::single_key(
                     vec![MODKEY, SHIFT],
                     keycodes::KEY_9,
                     KeyAction::MoveToTag,
diff --git a/src/window_manager.rs b/src/window_manager.rs
index 5ef536e..a30d97e 100644
--- a/src/window_manager.rs
+++ b/src/window_manager.rs
@@ -68,6 +68,7 @@ pub struct WindowManager {
     previous_focused: Option<Window>,
     display: *mut x11::xlib::Display,
     font: crate::bar::font::Font,
+    keychord_state: keyboard::handlers::KeychordState,
 }
 
 type WmResult<T> = Result<T, WmError>;
@@ -176,6 +177,7 @@ impl WindowManager {
             previous_focused: None,
             display,
             font,
+            keychord_state: keyboard::handlers::KeychordState::Idle,
         };
 
         window_manager.scan_existing_windows()?;
@@ -799,8 +801,75 @@ impl WindowManager {
             .unwrap_or_else(|| self.layout.symbol().to_string())
     }
 
+    fn get_keychord_indicator(&self) -> Option<String> {
+        match &self.keychord_state {
+            keyboard::handlers::KeychordState::Idle => None,
+            keyboard::handlers::KeychordState::InProgress { candidates, keys_pressed } => {
+                if candidates.is_empty() {
+                    return None;
+                }
+
+                let binding = &self.config.keybindings[candidates[0]];
+                let mut indicator = String::new();
+
+                for (i, key_press) in binding.keys.iter().take(*keys_pressed).enumerate() {
+                    if i > 0 {
+                        indicator.push(' ');
+                    }
+
+                    for modifier in &key_press.modifiers {
+                        indicator.push_str(Self::format_modifier(*modifier));
+                        indicator.push('+');
+                    }
+
+                    indicator.push_str(&self.format_keycode(key_press.key));
+                }
+
+                indicator.push('-');
+                Some(indicator)
+            }
+        }
+    }
+
+    fn format_modifier(modifier: KeyButMask) -> &'static str {
+        match modifier {
+            KeyButMask::MOD1 => "Alt",
+            KeyButMask::MOD4 => "Super",
+            KeyButMask::SHIFT => "Shift",
+            KeyButMask::CONTROL => "Ctrl",
+            _ => "Mod",
+        }
+    }
+
+    fn format_keycode(&self, keycode: Keycode) -> String {
+        unsafe {
+            let keysym = x11::xlib::XKeycodeToKeysym(self.display, keycode, 0);
+            if keysym == 0 {
+                return "?".to_string();
+            }
+
+            let name_ptr = x11::xlib::XKeysymToString(keysym);
+            if name_ptr.is_null() {
+                return "?".to_string();
+            }
+
+            let c_str = std::ffi::CStr::from_ptr(name_ptr);
+            match c_str.to_str() {
+                Ok(s) => {
+                    if s.len() == 1 {
+                        s.to_lowercase()
+                    } else {
+                        s.to_string()
+                    }
+                }
+                Err(_) => "?".to_string(),
+            }
+        }
+    }
+
     fn update_bar(&mut self) -> WmResult<()> {
         let layout_symbol = self.get_layout_symbol();
+        let keychord_indicator = self.get_keychord_indicator();
 
         for (monitor_index, monitor) in self.monitors.iter().enumerate() {
             if let Some(bar) = self.bars.get_mut(monitor_index) {
@@ -821,6 +890,7 @@ impl WindowManager {
                     occupied_tags,
                     draw_blocks,
                     &layout_symbol,
+                    keychord_indicator.as_deref(),
                 )?;
             }
         }
@@ -1124,6 +1194,39 @@ impl WindowManager {
         Ok(())
     }
 
+    fn grab_next_keys(&self, candidates: &[usize], keys_pressed: usize) -> WmResult<()> {
+        let mut grabbed_keys: HashSet<(u16, Keycode)> = HashSet::new();
+
+        for &candidate_index in candidates {
+            let binding = &self.config.keybindings[candidate_index];
+            if keys_pressed < binding.keys.len() {
+                let next_key = &binding.keys[keys_pressed];
+                let modifier_mask = keyboard::handlers::modifiers_to_mask(&next_key.modifiers);
+                let key_tuple = (modifier_mask, next_key.key);
+
+                if grabbed_keys.insert(key_tuple) {
+                    self.connection.grab_key(
+                        false,
+                        self.root,
+                        modifier_mask.into(),
+                        next_key.key,
+                        GrabMode::ASYNC,
+                        GrabMode::ASYNC,
+                    )?;
+                }
+            }
+        }
+        self.connection.flush()?;
+        Ok(())
+    }
+
+    fn ungrab_chord_keys(&self) -> WmResult<()> {
+        self.connection.ungrab_key(x11rb::protocol::xproto::Grab::ANY, self.root, ModMask::ANY)?;
+        keyboard::setup_keybinds(&self.connection, self.root, &self.config.keybindings)?;
+        self.connection.flush()?;
+        Ok(())
+    }
+
     pub fn set_focus(&mut self, window: Window) -> WmResult<()> {
         let old_focused = self.previous_focused;
 
@@ -1372,11 +1475,43 @@ impl WindowManager {
                 }
             }
             Event::KeyPress(event) => {
-                let (action, arg) = keyboard::handle_key_press(event, &self.config.keybindings);
-                match action {
-                    KeyAction::Quit => return Ok(Some(false)),
-                    KeyAction::Restart => return Ok(Some(true)),
-                    _ => self.handle_key_action(action, &arg)?,
+                let result = keyboard::handle_key_press(
+                    event,
+                    &self.config.keybindings,
+                    &self.keychord_state,
+                );
+
+                match result {
+                    keyboard::handlers::KeychordResult::Completed(action, arg) => {
+                        self.keychord_state = keyboard::handlers::KeychordState::Idle;
+                        self.ungrab_chord_keys()?;
+                        self.update_bar()?;
+
+                        match action {
+                            KeyAction::Quit => return Ok(Some(false)),
+                            KeyAction::Restart => return Ok(Some(true)),
+                            _ => self.handle_key_action(action, &arg)?,
+                        }
+                    }
+                    keyboard::handlers::KeychordResult::InProgress(candidates) => {
+                        let keys_pressed = match &self.keychord_state {
+                            keyboard::handlers::KeychordState::Idle => 1,
+                            keyboard::handlers::KeychordState::InProgress { keys_pressed, .. } => keys_pressed + 1,
+                        };
+
+                        self.keychord_state = keyboard::handlers::KeychordState::InProgress {
+                            candidates: candidates.clone(),
+                            keys_pressed,
+                        };
+
+                        self.grab_next_keys(&candidates, keys_pressed)?;
+                        self.update_bar()?;
+                    }
+                    keyboard::handlers::KeychordResult::Cancelled | keyboard::handlers::KeychordResult::None => {
+                        self.keychord_state = keyboard::handlers::KeychordState::Idle;
+                        self.ungrab_chord_keys()?;
+                        self.update_bar()?;
+                    }
                 }
             }
             Event::ButtonPress(event) => {
diff --git a/templates/config.ron b/templates/config.ron
index 69af9f1..8cdbb9a 100644
--- a/templates/config.ron
+++ b/templates/config.ron
@@ -23,7 +23,22 @@
         (name: "normie", symbol: "[F]"),
     ],
 
+    // Keybinding Format:
+    //
+    // New format (supports keychords - multi-key sequences):
+    //   (keys: [(modifiers: [...], key: X), (modifiers: [...], key: Y)], action: ..., arg: ...)
+    //
+    // Old format (single key, backwards compatible):
+    //   (modifiers: [...], key: X, action: ..., arg: ...)
+    //
+    // Examples:
+    //   Single key:  (keys: [(modifiers: [Mod4], key: Return)], action: Spawn, arg: "st")
+    //   Keychord:    (keys: [(modifiers: [Mod4], key: Space), (modifiers: [], key: F)], action: Spawn, arg: "firefox")
+    //
+    // You can cancel any in-progress keychord by pressing Escape.
+
     keybindings: [
+        // Basic keybindings (old format for backwards compatibility)
         (modifiers: [Mod4], key: Return, action: Spawn, arg: "st"),
         (modifiers: [Mod4], key: D, action: Spawn, arg: ["sh", "-c", "dmenu_run -l 10"]),
         (modifiers: [Mod4], key: S, action: Spawn, arg: ["sh", "-c", "maim -s | xclip -selection clipboard -t image/png"]),
@@ -70,6 +85,19 @@
         (modifiers: [Mod4, Shift], key: J, action: ExchangeClient, arg: 1), // DOWN
         (modifiers: [Mod4, Shift], key: H, action: ExchangeClient, arg: 2), // LEFT
         (modifiers: [Mod4, Shift], key: L, action: ExchangeClient, arg: 3), // RIGHT
+
+        // Example keychord bindings (uncomment to use):
+        // Press Mod4+Space, then f to spawn firefox
+        // (keys: [(modifiers: [Mod4], key: Space), (modifiers: [], key: F)], action: Spawn, arg: "firefox"),
+
+        // Press Mod4+Space, then t to spawn terminal
+        // (keys: [(modifiers: [Mod4], key: Space), (modifiers: [], key: T)], action: Spawn, arg: "st"),
+
+        // Press Mod4+e, then e to spawn a text editor (same key twice)
+        // (keys: [(modifiers: [Mod4], key: E), (modifiers: [], key: E)], action: Spawn, arg: "nvim"),
+
+        // Press Mod4+x, then Shift+c to kill client (modifier on second key)
+        // (keys: [(modifiers: [Mod4], key: X), (modifiers: [Shift], key: C)], action: KillClient),
     ],
     
     status_blocks: [
diff --git a/test-config.ron b/test-config.ron
index ac3303a..eb258fc 100644
--- a/test-config.ron
+++ b/test-config.ron
@@ -31,6 +31,7 @@
 
     keybindings: [
         (modifiers: [Mod1], key: Return, action: Spawn, arg: "st"),
+        (keys: [(modifiers: [Mod1], key: Space), (modifiers: [], key: T)], action: Spawn, arg: "st"),
         (modifiers: [Mod1], key: D, action: Spawn, arg: ["sh", "-c", "dmenu_run -l 10"]),
         (modifiers: [Mod1], key: S, action: Spawn, arg: ["sh", "-c", "maim -s | xclip -selection clipboard -t image/png"]),
         (modifiers: [Mod1], key: Q, action: KillClient),