Add an important hotkeys overlay

This commit is contained in:
Ivan Molodetskikh 2024-01-18 19:20:46 +04:00
parent edafa139f6
commit 011c91c98a
7 changed files with 500 additions and 22 deletions

View File

@ -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) |

View File

@ -429,6 +429,7 @@ pub enum Action {
MaximizeColumn,
SetColumnWidth(#[knuffel(argument, str)] SizeChange),
SwitchLayout(#[knuffel(argument)] LayoutAction),
ShowHotkeyOverlay,
}
#[derive(Debug, Clone, Copy, PartialEq)]

View File

@ -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; }

429
src/hotkey_overlay.rs Normal file
View File

@ -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()
}
}

View File

@ -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,

View File

@ -9,6 +9,7 @@ mod cursor;
mod dbus;
mod frame_clock;
mod handlers;
mod hotkey_overlay;
mod input;
mod ipc;
mod layout;

View File

@ -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);