diff options
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | niri-config/src/lib.rs | 1 | ||||
| -rw-r--r-- | resources/default-config.kdl | 48 | ||||
| -rw-r--r-- | src/hotkey_overlay.rs | 429 | ||||
| -rw-r--r-- | src/input.rs | 29 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/niri.rs | 13 |
7 files changed, 500 insertions, 22 deletions
@@ -144,6 +144,7 @@ The general system is: if a hotkey switches somewhere, then adding <kbd>Ctrl</kb | Hotkey | Description | | ------ | ----------- | +| <kbd>Mod</kbd><kbd>Shift</kbd><kbd>/</kbd> | Show a list of important niri hotkeys | | <kbd>Mod</kbd><kbd>T</kbd> | Spawn `alacritty` (terminal) | | <kbd>Mod</kbd><kbd>D</kbd> | Spawn `fuzzel` (application launcher) | | <kbd>Mod</kbd><kbd>Alt</kbd><kbd>L</kbd> | Spawn `swaylock` (screen locker) | diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index f61180f0..f05b78b9 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -429,6 +429,7 @@ pub enum Action { MaximizeColumn, SetColumnWidth(#[knuffel(argument, str)] SizeChange), SwitchLayout(#[knuffel(argument)] LayoutAction), + ShowHotkeyOverlay, } #[derive(Debug, Clone, Copy, PartialEq)] diff --git a/resources/default-config.kdl b/resources/default-config.kdl index c192467b..1a7d6f94 100644 --- a/resources/default-config.kdl +++ b/resources/default-config.kdl @@ -187,6 +187,10 @@ binds { // "Mod" is a special modifier equal to Super when running on a TTY, and to Alt // when running as a winit window. + // Mod-Shift-/, which is usually the same as Mod-?, + // shows a list of important hotkeys. + Mod+Shift+Slash { show-hotkey-overlay; } + // Suggested binds for running programs: terminal, app launcher, screen locker. Mod+T { spawn "alacritty"; } Mod+D { spawn "fuzzel"; } @@ -201,23 +205,23 @@ binds { Mod+Q { close-window; } - Mod+H { focus-column-left; } - Mod+J { focus-window-down; } - Mod+K { focus-window-up; } - Mod+L { focus-column-right; } Mod+Left { focus-column-left; } Mod+Down { focus-window-down; } Mod+Up { focus-window-up; } Mod+Right { focus-column-right; } + Mod+H { focus-column-left; } + Mod+J { focus-window-down; } + Mod+K { focus-window-up; } + Mod+L { focus-column-right; } - Mod+Ctrl+H { move-column-left; } - Mod+Ctrl+J { move-window-down; } - Mod+Ctrl+K { move-window-up; } - Mod+Ctrl+L { move-column-right; } Mod+Ctrl+Left { move-column-left; } Mod+Ctrl+Down { move-window-down; } Mod+Ctrl+Up { move-window-up; } Mod+Ctrl+Right { move-column-right; } + Mod+Ctrl+H { move-column-left; } + Mod+Ctrl+J { move-window-down; } + Mod+Ctrl+K { move-window-up; } + Mod+Ctrl+L { move-column-right; } // Alternative commands that move across workspaces when reaching // the first or last window in a column. @@ -231,45 +235,45 @@ binds { Mod+Ctrl+Home { move-column-to-first; } Mod+Ctrl+End { move-column-to-last; } - Mod+Shift+H { focus-monitor-left; } - Mod+Shift+J { focus-monitor-down; } - Mod+Shift+K { focus-monitor-up; } - Mod+Shift+L { focus-monitor-right; } Mod+Shift+Left { focus-monitor-left; } Mod+Shift+Down { focus-monitor-down; } Mod+Shift+Up { focus-monitor-up; } Mod+Shift+Right { focus-monitor-right; } + Mod+Shift+H { focus-monitor-left; } + Mod+Shift+J { focus-monitor-down; } + Mod+Shift+K { focus-monitor-up; } + Mod+Shift+L { focus-monitor-right; } - Mod+Shift+Ctrl+H { move-column-to-monitor-left; } - Mod+Shift+Ctrl+J { move-column-to-monitor-down; } - Mod+Shift+Ctrl+K { move-column-to-monitor-up; } - Mod+Shift+Ctrl+L { move-column-to-monitor-right; } Mod+Shift+Ctrl+Left { move-column-to-monitor-left; } Mod+Shift+Ctrl+Down { move-column-to-monitor-down; } Mod+Shift+Ctrl+Up { move-column-to-monitor-up; } Mod+Shift+Ctrl+Right { move-column-to-monitor-right; } + Mod+Shift+Ctrl+H { move-column-to-monitor-left; } + Mod+Shift+Ctrl+J { move-column-to-monitor-down; } + Mod+Shift+Ctrl+K { move-column-to-monitor-up; } + Mod+Shift+Ctrl+L { move-column-to-monitor-right; } // Alternatively, there are commands to move just a single window: // Mod+Shift+Ctrl+Left { move-window-to-monitor-left; } // ... - Mod+U { focus-workspace-down; } - Mod+I { focus-workspace-up; } Mod+Page_Down { focus-workspace-down; } Mod+Page_Up { focus-workspace-up; } - Mod+Ctrl+U { move-column-to-workspace-down; } - Mod+Ctrl+I { move-column-to-workspace-up; } + Mod+U { focus-workspace-down; } + Mod+I { focus-workspace-up; } Mod+Ctrl+Page_Down { move-column-to-workspace-down; } Mod+Ctrl+Page_Up { move-column-to-workspace-up; } + Mod+Ctrl+U { move-column-to-workspace-down; } + Mod+Ctrl+I { move-column-to-workspace-up; } // Alternatively, there are commands to move just a single window: // Mod+Ctrl+Page_Down { move-window-to-workspace-down; } // ... - Mod+Shift+U { move-workspace-down; } - Mod+Shift+I { move-workspace-up; } Mod+Shift+Page_Down { move-workspace-down; } Mod+Shift+Page_Up { move-workspace-up; } + Mod+Shift+U { move-workspace-down; } + Mod+Shift+I { move-workspace-up; } Mod+1 { focus-workspace 1; } Mod+2 { focus-workspace 2; } diff --git a/src/hotkey_overlay.rs b/src/hotkey_overlay.rs new file mode 100644 index 00000000..5aa898a3 --- /dev/null +++ b/src/hotkey_overlay.rs @@ -0,0 +1,429 @@ +use std::cell::RefCell; +use std::cmp::max; +use std::collections::HashMap; +use std::iter::zip; +use std::rc::Rc; + +use niri_config::{Action, Config, Key, Modifiers}; +use pangocairo::cairo::{self, ImageSurface}; +use pangocairo::pango::{AttrColor, AttrInt, AttrList, AttrString, FontDescription, Weight}; +use smithay::backend::renderer::element::memory::{ + MemoryRenderBuffer, MemoryRenderBufferRenderElement, +}; +use smithay::backend::renderer::element::utils::{Relocate, RelocateRenderElement}; +use smithay::backend::renderer::element::Kind; +use smithay::input::keyboard::xkb::keysym_get_name; +use smithay::output::{Output, WeakOutput}; +use smithay::reexports::gbm::Format as Fourcc; +use smithay::utils::{Physical, Size, Transform}; + +use crate::input::CompositorMod; +use crate::render_helpers::NiriRenderer; + +const PADDING: i32 = 8; +const MARGIN: i32 = PADDING * 2; +const FONT: &str = "sans 14px"; +const BORDER: i32 = 4; +const LINE_INTERVAL: i32 = 2; +const TITLE: &str = "Important Hotkeys"; + +pub struct HotkeyOverlay { + is_open: bool, + config: Rc<RefCell<Config>>, + comp_mod: CompositorMod, + buffers: RefCell<HashMap<WeakOutput, RenderedOverlay>>, +} + +pub struct RenderedOverlay { + buffer: Option<MemoryRenderBuffer>, + size: Size<i32, Physical>, + scale: i32, +} + +pub type HotkeyOverlayRenderElement<R> = RelocateRenderElement<MemoryRenderBufferRenderElement<R>>; + +impl HotkeyOverlay { + pub fn new(config: Rc<RefCell<Config>>, comp_mod: CompositorMod) -> Self { + Self { + // Start the compositor with the overlay open. + is_open: true, + config, + comp_mod, + buffers: RefCell::new(HashMap::new()), + } + } + + pub fn show(&mut self) -> bool { + if !self.is_open { + self.is_open = true; + true + } else { + false + } + } + + pub fn hide(&mut self) -> bool { + if self.is_open { + self.is_open = false; + true + } else { + false + } + } + + pub fn is_open(&self) -> bool { + self.is_open + } + + pub fn on_hotkey_config_updated(&mut self) { + self.buffers.borrow_mut().clear(); + } + + pub fn render<R: NiriRenderer>( + &self, + renderer: &mut R, + output: &Output, + ) -> Option<HotkeyOverlayRenderElement<R>> { + if !self.is_open { + return None; + } + + let scale = output.current_scale().integer_scale(); + let margin = MARGIN * scale; + + let output_transform = output.current_transform(); + let output_mode = output.current_mode().unwrap(); + let output_size = output_transform.transform_size(output_mode.size); + + let mut buffers = self.buffers.borrow_mut(); + buffers.retain(|output, _| output.upgrade().is_some()); + + // FIXME: should probably use the working area rather than view size. + let weak = output.downgrade(); + if let Some(rendered) = buffers.get(&weak) { + if rendered.scale != scale { + buffers.remove(&weak); + } + } + + let rendered = buffers.entry(weak).or_insert_with(|| { + render(&self.config.borrow(), self.comp_mod, scale).unwrap_or_else(|_| { + // This can go negative but whatever, as long as there's no rerender loop. + let mut size = output_size; + size.w -= margin * 2; + size.h -= margin * 2; + RenderedOverlay { + buffer: None, + size, + scale, + } + }) + }); + let buffer = rendered.buffer.as_ref()?; + + let elem = MemoryRenderBufferRenderElement::from_buffer( + renderer, + (0., 0.), + buffer, + Some(0.9), + None, + None, + Kind::Unspecified, + ) + .ok()?; + + let x = (output_size.w / 2 - rendered.size.w / 2).max(0); + let y = (output_size.h / 2 - rendered.size.h / 2).max(0); + let elem = RelocateRenderElement::from_element(elem, (x, y), Relocate::Absolute); + + Some(elem) + } +} + +fn render(config: &Config, comp_mod: CompositorMod, scale: i32) -> anyhow::Result<RenderedOverlay> { + let _span = tracy_client::span!("hotkey_overlay::render"); + + // let margin = MARGIN * scale; + let padding = PADDING * scale; + let line_interval = LINE_INTERVAL * scale; + + // FIXME: if it doesn't fit, try splitting in two columns or something. + // let mut target_size = output_size; + // target_size.w -= margin * 2; + // target_size.h -= margin * 2; + // anyhow::ensure!(target_size.w > 0 && target_size.h > 0); + + let binds = &config.binds.0; + + // Collect actions that we want to show. + let mut actions = vec![ + &Action::ShowHotkeyOverlay, + &Action::Quit, + &Action::CloseWindow, + ]; + + actions.extend(&[ + &Action::FocusColumnLeft, + &Action::FocusColumnRight, + &Action::MoveColumnLeft, + &Action::MoveColumnRight, + &Action::FocusWorkspaceDown, + &Action::FocusWorkspaceUp, + ]); + + // Prefer move-column-to-workspace-down, but fall back to move-window-to-workspace-down. + if binds + .iter() + .any(|bind| bind.actions.first() == Some(&Action::MoveColumnToWorkspaceDown)) + { + actions.push(&Action::MoveColumnToWorkspaceDown); + } else if binds + .iter() + .any(|bind| bind.actions.first() == Some(&Action::MoveWindowToWorkspaceDown)) + { + actions.push(&Action::MoveWindowToWorkspaceDown); + } else { + actions.push(&Action::MoveColumnToWorkspaceDown); + } + + // Same for -up. + if binds + .iter() + .any(|bind| bind.actions.first() == Some(&Action::MoveColumnToWorkspaceUp)) + { + actions.push(&Action::MoveColumnToWorkspaceUp); + } else if binds + .iter() + .any(|bind| bind.actions.first() == Some(&Action::MoveWindowToWorkspaceUp)) + { + actions.push(&Action::MoveWindowToWorkspaceUp); + } else { + actions.push(&Action::MoveColumnToWorkspaceUp); + } + + actions.extend(&[ + &Action::SwitchPresetColumnWidth, + &Action::MaximizeColumn, + &Action::ConsumeWindowIntoColumn, + &Action::ExpelWindowFromColumn, + ]); + + // Screenshot is not as important, can omit if not bound. + if binds + .iter() + .any(|bind| bind.actions.first() == Some(&Action::Screenshot)) + { + actions.push(&Action::Screenshot); + } + + // Add the spawn actions. + for bind in binds + .iter() + .filter(|bind| matches!(bind.actions.first(), Some(Action::Spawn(_)))) + { + actions.push(bind.actions.first().unwrap()); + } + + let strings = actions + .into_iter() + .map(|action| { + let key = config + .binds + .0 + .iter() + .find(|bind| bind.actions.first() == Some(action)) + .map(|bind| key_name(comp_mod, &bind.key)) + .unwrap_or_else(|| String::from("(not bound)")); + + (format!(" {key} "), action_name(action)) + }) + .collect::<Vec<_>>(); + + let mut font = FontDescription::from_string(FONT); + font.set_absolute_size((font.size() * scale).into()); + + let surface = ImageSurface::create(cairo::Format::ARgb32, 0, 0)?; + let cr = cairo::Context::new(&surface)?; + let layout = pangocairo::create_layout(&cr); + layout.set_font_description(Some(&font)); + + let bold = AttrList::new(); + bold.insert(AttrInt::new_weight(Weight::Bold)); + layout.set_attributes(Some(&bold)); + layout.set_text(TITLE); + let title_size = layout.pixel_size(); + + let attrs = AttrList::new(); + attrs.insert(AttrString::new_family("Monospace")); + attrs.insert(AttrColor::new_background(12000, 12000, 12000)); + + layout.set_attributes(Some(&attrs)); + let key_sizes = strings + .iter() + .map(|(key, _)| { + layout.set_text(key); + layout.pixel_size() + }) + .collect::<Vec<_>>(); + + layout.set_attributes(None); + let action_sizes = strings + .iter() + .map(|(_, action)| { + layout.set_markup(action); + layout.pixel_size() + }) + .collect::<Vec<_>>(); + + let key_width = key_sizes.iter().map(|(w, _)| w).max().unwrap(); + let action_width = action_sizes.iter().map(|(w, _)| w).max().unwrap(); + let mut width = key_width + padding + action_width; + + let mut height = zip(&key_sizes, &action_sizes) + .map(|((_, key_h), (_, act_h))| max(key_h, act_h)) + .sum::<i32>() + + (key_sizes.len() - 1) as i32 * line_interval + + title_size.1 + + padding; + + width += padding * 2; + height += padding * 2; + + // FIXME: fix bug in Smithay that rounds pixel sizes down to scale. + width = (width + scale - 1) / scale * scale; + height = (height + scale - 1) / scale * scale; + + let surface = ImageSurface::create(cairo::Format::ARgb32, width, height)?; + let cr = cairo::Context::new(&surface)?; + cr.set_source_rgb(0.1, 0.1, 0.1); + cr.paint()?; + + cr.move_to(padding.into(), padding.into()); + let layout = pangocairo::create_layout(&cr); + layout.set_font_description(Some(&font)); + + cr.set_source_rgb(1., 1., 1.); + + cr.move_to(((width - title_size.0) / 2).into(), padding.into()); + layout.set_attributes(Some(&bold)); + layout.set_text(TITLE); + pangocairo::show_layout(&cr, &layout); + + cr.move_to(padding.into(), (padding + title_size.1 + padding).into()); + + for ((key, action), ((_, key_h), (_, act_h))) in zip(&strings, zip(&key_sizes, &action_sizes)) { + layout.set_attributes(Some(&attrs)); + layout.set_text(key); + pangocairo::show_layout(&cr, &layout); + + cr.rel_move_to((key_width + padding).into(), 0.); + + layout.set_attributes(None); + layout.set_markup(action); + pangocairo::show_layout(&cr, &layout); + + cr.rel_move_to( + (-(key_width + padding)).into(), + (max(key_h, act_h) + line_interval).into(), + ); + } + + cr.move_to(0., 0.); + cr.line_to(width.into(), 0.); + cr.line_to(width.into(), height.into()); + cr.line_to(0., height.into()); + cr.line_to(0., 0.); + cr.set_source_rgb(0.5, 0.8, 1.0); + cr.set_line_width((BORDER * scale).into()); + cr.stroke()?; + drop(cr); + + let data = surface.take_data().unwrap(); + let buffer = MemoryRenderBuffer::from_memory( + &data, + Fourcc::Argb8888, + (width, height), + scale, + Transform::Normal, + None, + ); + + Ok(RenderedOverlay { + buffer: Some(buffer), + size: Size::from((width, height)), + scale, + }) +} + +fn action_name(action: &Action) -> String { + match action { + Action::Quit => String::from("Exit niri"), + Action::ShowHotkeyOverlay => String::from("Show Important Hotkeys"), + Action::CloseWindow => String::from("Close Focused Window"), + Action::FocusColumnLeft => String::from("Focus Column to the Left"), + Action::FocusColumnRight => String::from("Focus Column to the Right"), + Action::MoveColumnLeft => String::from("Move Column Left"), + Action::MoveColumnRight => String::from("Move Column Right"), + Action::FocusWorkspaceDown => String::from("Switch Workspace Down"), + Action::FocusWorkspaceUp => String::from("Switch Workspace Up"), + Action::MoveColumnToWorkspaceDown => String::from("Move Column to Workspace Down"), + Action::MoveColumnToWorkspaceUp => String::from("Move Column to Workspace Up"), + Action::MoveWindowToWorkspaceDown => String::from("Move Window to Workspace Down"), + Action::MoveWindowToWorkspaceUp => String::from("Move Window to Workspace Up"), + Action::SwitchPresetColumnWidth => String::from("Switch Preset Column Widths"), + Action::MaximizeColumn => String::from("Maximize Column"), + Action::ConsumeWindowIntoColumn => String::from("Consume Window Into Column"), + Action::ExpelWindowFromColumn => String::from("Expel Window From Column"), + Action::Screenshot => String::from("Take a Screenshot"), + Action::Spawn(args) => format!( + "Spawn <span face='monospace' bgcolor='#000000'>{}</span>", + args.first().unwrap_or(&String::new()) + ), + _ => String::from("FIXME: Unknown"), + } +} + +fn key_name(comp_mod: CompositorMod, key: &Key) -> String { + let mut name = String::new(); + + let has_comp_mod = key.modifiers.contains(Modifiers::COMPOSITOR); + + if key.modifiers.contains(Modifiers::SUPER) + || (has_comp_mod && comp_mod == CompositorMod::Super) + { + name.push_str("Super + "); + } + if key.modifiers.contains(Modifiers::ALT) || (has_comp_mod && comp_mod == CompositorMod::Alt) { + name.push_str("Alt + "); + } + if key.modifiers.contains(Modifiers::SHIFT) { + name.push_str("Shift + "); + } + if key.modifiers.contains(Modifiers::CTRL) { + name.push_str("Ctrl + "); + } + name.push_str(&prettify_keysym_name(&keysym_get_name(key.keysym))); + + name +} + +fn prettify_keysym_name(name: &str) -> String { + let name = match name { + "slash" => "/", + "comma" => ",", + "period" => ".", + "minus" => "-", + "equal" => "=", + "grave" => "`", + "Next" => "Page Down", + "Prior" => "Page Up", + "Print" => "PrtSc", + _ => name, + }; + + if name.len() == 1 && name.is_ascii() { + name.to_ascii_uppercase() + } else { + name.into() + } +} diff --git a/src/input.rs b/src/input.rs index 293bda93..595be712 100644 --- a/src/input.rs +++ b/src/input.rs @@ -54,6 +54,9 @@ impl State { self.niri.activate_monitors(&self.backend); } + let hide_hotkey_overlay = + self.niri.hotkey_overlay.is_open() && should_hide_hotkey_overlay(&event); + use InputEvent::*; match event { DeviceAdded { device } => self.on_device_added(device), @@ -82,6 +85,12 @@ impl State { TouchFrame { .. } => (), Special(_) => (), } + + // Do this last so that screenshot still gets it. + // FIXME: do this in a less cursed fashion somehow. + if hide_hotkey_overlay && self.niri.hotkey_overlay.hide() { + self.niri.queue_redraw_all(); + } } pub fn process_libinput_event(&mut self, event: &mut InputEvent<LibinputInputBackend>) { @@ -557,6 +566,11 @@ impl State { Action::SetWindowHeight(change) => { self.niri.layout.set_window_height(change); } + Action::ShowHotkeyOverlay => { + if self.niri.hotkey_overlay.show() { + self.niri.queue_redraw_all(); + } + } } } @@ -1372,6 +1386,21 @@ fn should_activate_monitors<I: InputBackend>(event: &InputEvent<I>) -> bool { } } +fn should_hide_hotkey_overlay<I: InputBackend>(event: &InputEvent<I>) -> bool { + match event { + InputEvent::Keyboard { event } if event.state() == KeyState::Pressed => true, + InputEvent::PointerButton { .. } + | InputEvent::PointerAxis { .. } + | InputEvent::GestureSwipeBegin { .. } + | InputEvent::GesturePinchBegin { .. } + | InputEvent::TouchDown { .. } + | InputEvent::TouchMotion { .. } + | InputEvent::TabletToolTip { .. } + | InputEvent::TabletToolButton { .. } => true, + _ => false, + } +} + fn allowed_when_locked(action: &Action) -> bool { matches!( action, diff --git a/src/main.rs b/src/main.rs index 9fe1394a..f4c7c70c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod cursor; mod dbus; mod frame_clock; mod handlers; +mod hotkey_overlay; mod input; mod ipc; mod layout; diff --git a/src/niri.rs b/src/niri.rs index 8109f2eb..16e0f2d6 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -100,6 +100,7 @@ use crate::dbus::gnome_shell_screenshot::{NiriToScreenshot, ScreenshotToNiri}; use crate::dbus::mutter_screen_cast::{self, ScreenCastToNiri}; use crate::frame_clock::FrameClock; use crate::handlers::configure_lock_surface; +use crate::hotkey_overlay::HotkeyOverlay; use crate::input::{apply_libinput_settings, TabletData}; use crate::ipc::server::IpcServer; use crate::layout::{Layout, MonitorRenderElement}; @@ -189,6 +190,7 @@ pub struct Niri { pub screenshot_ui: ScreenshotUi, pub config_error_notification: ConfigErrorNotification, + pub hotkey_overlay: HotkeyOverlay, #[cfg(feature = "dbus")] pub dbus: Option<crate::dbus::DBusServers>, @@ -594,6 +596,10 @@ impl State { output_config_changed = true; } + if config.binds != old_config.binds { + self.niri.hotkey_overlay.on_hotkey_config_updated(); + } + *old_config = config; // Release the borrow. @@ -859,6 +865,7 @@ impl Niri { let screenshot_ui = ScreenshotUi::new(); let config_error_notification = ConfigErrorNotification::new(); + let hotkey_overlay = HotkeyOverlay::new(config.clone(), backend.mod_key()); let socket_source = ListeningSocketSource::new_auto().unwrap(); let socket_name = socket_source.socket_name().to_os_string(); @@ -964,6 +971,7 @@ impl Niri { screenshot_ui, config_error_notification, + hotkey_overlay, #[cfg(feature = "dbus")] dbus: None, @@ -1858,6 +1866,11 @@ impl Niri { return elements; } + // Draw the hotkey overlay on top. + if let Some(element) = self.hotkey_overlay.render(renderer, output) { + elements.push(element.into()); + } + // Get monitor elements. let mon = self.layout.monitor_for_output(output).unwrap(); let monitor_elements = mon.render_elements(renderer); |
