aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
authorIvan Molodetskikh <yalterz@gmail.com>2024-02-21 10:50:30 +0400
committerIvan Molodetskikh <yalterz@gmail.com>2024-02-21 10:50:30 +0400
commitd1fe6930a7bc5b78b0f005c2105c92094fc4ec52 (patch)
tree724f55cfb29695f74bf1689ea108da4ca63f36fa /src/ui
parent9e60b344d06979ade51e49eaa766198e3133b909 (diff)
downloadniri-d1fe6930a7bc5b78b0f005c2105c92094fc4ec52.tar.gz
niri-d1fe6930a7bc5b78b0f005c2105c92094fc4ec52.tar.bz2
niri-d1fe6930a7bc5b78b0f005c2105c92094fc4ec52.zip
Move UI elements into submodule
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/config_error_notification.rs252
-rw-r--r--src/ui/exit_confirm_dialog.rs162
-rw-r--r--src/ui/hotkey_overlay.rs451
-rw-r--r--src/ui/mod.rs4
-rw-r--r--src/ui/screenshot_ui.rs453
5 files changed, 1322 insertions, 0 deletions
diff --git a/src/ui/config_error_notification.rs b/src/ui/config_error_notification.rs
new file mode 100644
index 00000000..12d92b85
--- /dev/null
+++ b/src/ui/config_error_notification.rs
@@ -0,0 +1,252 @@
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::time::Duration;
+
+use niri_config::Config;
+use pangocairo::cairo::{self, ImageSurface};
+use pangocairo::pango::FontDescription;
+use smithay::backend::renderer::element::memory::{
+ MemoryRenderBuffer, MemoryRenderBufferRenderElement,
+};
+use smithay::backend::renderer::element::utils::{Relocate, RelocateRenderElement};
+use smithay::backend::renderer::element::{Element, Kind};
+use smithay::output::Output;
+use smithay::reexports::gbm::Format as Fourcc;
+use smithay::utils::Transform;
+
+use crate::animation::Animation;
+use crate::render_helpers::renderer::NiriRenderer;
+
+const TEXT: &str = "Failed to parse the config file. \
+ Please run <span face='monospace' bgcolor='#000000'>niri validate</span> \
+ to see the errors.";
+const PADDING: i32 = 8;
+const FONT: &str = "sans 14px";
+const BORDER: i32 = 4;
+
+pub struct ConfigErrorNotification {
+ state: State,
+ buffers: RefCell<HashMap<i32, Option<MemoryRenderBuffer>>>,
+
+ // If set, this is a "Created config at {path}" notification. If unset, this is a config error
+ // notification.
+ created_path: Option<PathBuf>,
+
+ config: Rc<RefCell<Config>>,
+}
+
+enum State {
+ Hidden,
+ Showing(Animation),
+ Shown(Duration),
+ Hiding(Animation),
+}
+
+pub type ConfigErrorNotificationRenderElement<R> =
+ RelocateRenderElement<MemoryRenderBufferRenderElement<R>>;
+
+impl ConfigErrorNotification {
+ pub fn new(config: Rc<RefCell<Config>>) -> Self {
+ Self {
+ state: State::Hidden,
+ buffers: RefCell::new(HashMap::new()),
+ created_path: None,
+ config,
+ }
+ }
+
+ fn animation(&self, from: f64, to: f64) -> Animation {
+ let c = self.config.borrow();
+ Animation::new(
+ from,
+ to,
+ c.animations.config_notification_open_close,
+ niri_config::Animation::default_config_notification_open_close(),
+ )
+ }
+
+ pub fn show_created(&mut self, created_path: Option<PathBuf>) {
+ if self.created_path != created_path {
+ self.created_path = created_path;
+ self.buffers.borrow_mut().clear();
+ }
+
+ self.state = State::Showing(self.animation(0., 1.));
+ }
+
+ pub fn show(&mut self) {
+ if self.created_path.is_some() {
+ self.created_path = None;
+ self.buffers.borrow_mut().clear();
+ }
+
+ // Show from scratch even if already showing to bring attention.
+ self.state = State::Showing(self.animation(0., 1.));
+ }
+
+ pub fn hide(&mut self) {
+ if matches!(self.state, State::Hidden) {
+ return;
+ }
+
+ self.state = State::Hiding(self.animation(1., 0.));
+ }
+
+ pub fn advance_animations(&mut self, target_presentation_time: Duration) {
+ match &mut self.state {
+ State::Hidden => (),
+ State::Showing(anim) => {
+ anim.set_current_time(target_presentation_time);
+ if anim.is_done() {
+ let duration = if self.created_path.is_some() {
+ // Make this quite a bit longer because it comes with a monitor modeset
+ // (can take a while) and an important hotkeys popup diverting the
+ // attention.
+ Duration::from_secs(8)
+ } else {
+ Duration::from_secs(4)
+ };
+ self.state = State::Shown(target_presentation_time + duration);
+ }
+ }
+ State::Shown(deadline) => {
+ if target_presentation_time >= *deadline {
+ self.hide();
+ }
+ }
+ State::Hiding(anim) => {
+ anim.set_current_time(target_presentation_time);
+ if anim.is_done() {
+ self.state = State::Hidden;
+ }
+ }
+ }
+ }
+
+ pub fn are_animations_ongoing(&self) -> bool {
+ !matches!(self.state, State::Hidden)
+ }
+
+ pub fn render<R: NiriRenderer>(
+ &self,
+ renderer: &mut R,
+ output: &Output,
+ ) -> Option<ConfigErrorNotificationRenderElement<R>> {
+ if matches!(self.state, State::Hidden) {
+ return None;
+ }
+
+ let scale = output.current_scale().integer_scale();
+ let path = self.created_path.as_deref();
+
+ let mut buffers = self.buffers.borrow_mut();
+ let buffer = buffers
+ .entry(scale)
+ .or_insert_with_key(move |&scale| render(scale, path).ok());
+ let buffer = buffer.as_ref()?;
+
+ let elem = MemoryRenderBufferRenderElement::from_buffer(
+ renderer,
+ (0., 0.),
+ buffer,
+ Some(0.9),
+ None,
+ None,
+ Kind::Unspecified,
+ )
+ .ok()?;
+
+ let output_transform = output.current_transform();
+ let output_mode = output.current_mode().unwrap();
+ let output_size = output_transform.transform_size(output_mode.size);
+
+ let buffer_size = elem
+ .geometry(output.current_scale().fractional_scale().into())
+ .size;
+
+ let y_range = buffer_size.h + PADDING * 2 * scale;
+
+ let x = (output_size.w / 2 - buffer_size.w / 2).max(0);
+ let y = match &self.state {
+ State::Hidden => unreachable!(),
+ State::Showing(anim) | State::Hiding(anim) => {
+ (-buffer_size.h as f64 + anim.value() * y_range as f64).round() as i32
+ }
+ State::Shown(_) => PADDING * 2 * scale,
+ };
+ let elem = RelocateRenderElement::from_element(elem, (x, y), Relocate::Absolute);
+
+ Some(elem)
+ }
+}
+
+fn render(scale: i32, created_path: Option<&Path>) -> anyhow::Result<MemoryRenderBuffer> {
+ let _span = tracy_client::span!("config_error_notification::render");
+
+ let padding = PADDING * scale;
+
+ let mut text = String::from(TEXT);
+ let mut border_color = (1., 0.3, 0.3);
+ if let Some(path) = created_path {
+ text = format!(
+ "Created a default config file at \
+ <span face='monospace' bgcolor='#000000'>{:?}</span>",
+ path
+ );
+ border_color = (0.5, 1., 0.5);
+ };
+
+ 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::functions::create_layout(&cr);
+ layout.set_font_description(Some(&font));
+ layout.set_markup(&text);
+
+ let (mut width, mut height) = layout.pixel_size();
+ 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::functions::create_layout(&cr);
+ layout.set_font_description(Some(&font));
+ layout.set_markup(&text);
+
+ cr.set_source_rgb(1., 1., 1.);
+ pangocairo::functions::show_layout(&cr, &layout);
+
+ 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(border_color.0, border_color.1, border_color.2);
+ cr.set_line_width((BORDER * scale).into());
+ cr.stroke()?;
+ drop(cr);
+
+ let data = surface.take_data().unwrap();
+ let buffer = MemoryRenderBuffer::from_slice(
+ &data,
+ Fourcc::Argb8888,
+ (width, height),
+ scale,
+ Transform::Normal,
+ None,
+ );
+
+ Ok(buffer)
+}
diff --git a/src/ui/exit_confirm_dialog.rs b/src/ui/exit_confirm_dialog.rs
new file mode 100644
index 00000000..84eddefe
--- /dev/null
+++ b/src/ui/exit_confirm_dialog.rs
@@ -0,0 +1,162 @@
+use std::cell::RefCell;
+use std::collections::HashMap;
+
+use pangocairo::cairo::{self, ImageSurface};
+use pangocairo::pango::{Alignment, FontDescription};
+use smithay::backend::renderer::element::memory::{
+ MemoryRenderBuffer, MemoryRenderBufferRenderElement,
+};
+use smithay::backend::renderer::element::utils::{Relocate, RelocateRenderElement};
+use smithay::backend::renderer::element::{Element, Kind};
+use smithay::output::Output;
+use smithay::reexports::gbm::Format as Fourcc;
+use smithay::utils::Transform;
+
+use crate::render_helpers::renderer::NiriRenderer;
+
+const TEXT: &str = "Are you sure you want to exit niri?\n\n\
+ Press <span face='mono' bgcolor='#2C2C2C'> Enter </span> to confirm.";
+const PADDING: i32 = 16;
+const FONT: &str = "sans 14px";
+const BORDER: i32 = 8;
+
+pub struct ExitConfirmDialog {
+ is_open: bool,
+ buffers: RefCell<HashMap<i32, Option<MemoryRenderBuffer>>>,
+}
+
+pub type ExitConfirmDialogRenderElement<R> =
+ RelocateRenderElement<MemoryRenderBufferRenderElement<R>>;
+
+impl ExitConfirmDialog {
+ pub fn new() -> anyhow::Result<Self> {
+ Ok(Self {
+ is_open: false,
+ buffers: RefCell::new(HashMap::from([(1, Some(render(1)?))])),
+ })
+ }
+
+ 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 render<R: NiriRenderer>(
+ &self,
+ renderer: &mut R,
+ output: &Output,
+ ) -> Option<ExitConfirmDialogRenderElement<R>> {
+ if !self.is_open {
+ return None;
+ }
+
+ let scale = output.current_scale().integer_scale();
+
+ let mut buffers = self.buffers.borrow_mut();
+ let fallback = buffers[&1].clone().unwrap();
+ let buffer = buffers.entry(scale).or_insert_with(|| render(scale).ok());
+ let buffer = buffer.as_ref().unwrap_or(&fallback);
+
+ let elem = MemoryRenderBufferRenderElement::from_buffer(
+ renderer,
+ (0., 0.),
+ buffer,
+ None,
+ None,
+ None,
+ Kind::Unspecified,
+ )
+ .ok()?;
+
+ let output_transform = output.current_transform();
+ let output_mode = output.current_mode().unwrap();
+ let output_size = output_transform.transform_size(output_mode.size);
+
+ let buffer_size = elem
+ .geometry(output.current_scale().fractional_scale().into())
+ .size;
+
+ let x = (output_size.w / 2 - buffer_size.w / 2).max(0);
+ let y = (output_size.h / 2 - buffer_size.h / 2).max(0);
+ let elem = RelocateRenderElement::from_element(elem, (x, y), Relocate::Absolute);
+
+ Some(elem)
+ }
+}
+
+fn render(scale: i32) -> anyhow::Result<MemoryRenderBuffer> {
+ let _span = tracy_client::span!("exit_confirm_dialog::render");
+
+ let padding = PADDING * scale;
+
+ 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::functions::create_layout(&cr);
+ layout.set_font_description(Some(&font));
+ layout.set_alignment(Alignment::Center);
+ layout.set_markup(TEXT);
+
+ let (mut width, mut height) = layout.pixel_size();
+ 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::functions::create_layout(&cr);
+ layout.set_font_description(Some(&font));
+ layout.set_alignment(Alignment::Center);
+ layout.set_markup(TEXT);
+
+ cr.set_source_rgb(1., 1., 1.);
+ pangocairo::functions::show_layout(&cr, &layout);
+
+ 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(1., 0.3, 0.3);
+ cr.set_line_width((BORDER * scale).into());
+ cr.stroke()?;
+ drop(cr);
+
+ let data = surface.take_data().unwrap();
+ let buffer = MemoryRenderBuffer::from_slice(
+ &data,
+ Fourcc::Argb8888,
+ (width, height),
+ scale,
+ Transform::Normal,
+ None,
+ );
+
+ Ok(buffer)
+}
diff --git a/src/ui/hotkey_overlay.rs b/src/ui/hotkey_overlay.rs
new file mode 100644
index 00000000..bfb263f4
--- /dev/null
+++ b/src/ui/hotkey_overlay.rs
@@ -0,0 +1,451 @@
+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::renderer::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 {
+ is_open: false,
+ 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];
+
+ // Prefer Quit(false) if found, otherwise try Quit(true), and if there's neither, fall back to
+ // Quit(false).
+ if binds
+ .iter()
+ .any(|bind| bind.actions.first() == Some(&Action::Quit(false)))
+ {
+ actions.push(&Action::Quit(false));
+ } else if binds
+ .iter()
+ .any(|bind| bind.actions.first() == Some(&Action::Quit(true)))
+ {
+ actions.push(&Action::Quit(true));
+ } else {
+ actions.push(&Action::Quit(false));
+ }
+
+ actions.extend(&[
+ &Action::CloseWindow,
+ &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.
+ let mut spawn_actions = Vec::new();
+ for bind in binds.iter().filter(|bind| {
+ matches!(bind.actions.first(), Some(Action::Spawn(_)))
+ // Only show binds with Mod or Super to filter out stuff like volume up/down.
+ && (bind.key.modifiers.contains(Modifiers::COMPOSITOR)
+ || bind.key.modifiers.contains(Modifiers::SUPER))
+ }) {
+ let action = bind.actions.first().unwrap();
+
+ // We only show one bind for each action, so we need to deduplicate the Spawn actions.
+ if !spawn_actions.contains(&action) {
+ spawn_actions.push(action);
+ }
+ }
+ actions.extend(spawn_actions);
+
+ 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::functions::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::functions::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::functions::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::functions::show_layout(&cr, &layout);
+
+ cr.rel_move_to((key_width + padding).into(), 0.);
+
+ layout.set_attributes(None);
+ layout.set_markup(action);
+ pangocairo::functions::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_slice(
+ &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",
+ "Return" => "Enter",
+ _ => name,
+ };
+
+ if name.len() == 1 && name.is_ascii() {
+ name.to_ascii_uppercase()
+ } else {
+ name.into()
+ }
+}
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
new file mode 100644
index 00000000..a63c6f03
--- /dev/null
+++ b/src/ui/mod.rs
@@ -0,0 +1,4 @@
+pub mod config_error_notification;
+pub mod exit_confirm_dialog;
+pub mod hotkey_overlay;
+pub mod screenshot_ui;
diff --git a/src/ui/screenshot_ui.rs b/src/ui/screenshot_ui.rs
new file mode 100644
index 00000000..897761a1
--- /dev/null
+++ b/src/ui/screenshot_ui.rs
@@ -0,0 +1,453 @@
+use std::cmp::{max, min};
+use std::collections::HashMap;
+use std::iter::zip;
+use std::mem;
+
+use anyhow::Context;
+use arrayvec::ArrayVec;
+use niri_config::Action;
+use smithay::backend::allocator::Fourcc;
+use smithay::backend::input::{ButtonState, MouseButton};
+use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement};
+use smithay::backend::renderer::element::texture::{TextureBuffer, TextureRenderElement};
+use smithay::backend::renderer::element::Kind;
+use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
+use smithay::backend::renderer::ExportMem;
+use smithay::input::keyboard::{Keysym, ModifiersState};
+use smithay::output::{Output, WeakOutput};
+use smithay::utils::{Physical, Point, Rectangle, Size, Transform};
+
+use crate::niri_render_elements;
+use crate::render_helpers::primary_gpu_texture::PrimaryGpuTextureRenderElement;
+
+const BORDER: i32 = 2;
+
+// Ideally the screenshot UI should support cross-output selections. However, that poses some
+// technical challenges when the outputs have different scales and such. So, this implementation
+// allows only single-output selections for now.
+//
+// As a consequence of this, selection coordinates are in output-local coordinate space.
+pub enum ScreenshotUi {
+ Closed {
+ last_selection: Option<(WeakOutput, Rectangle<i32, Physical>)>,
+ },
+ Open {
+ selection: (Output, Point<i32, Physical>, Point<i32, Physical>),
+ output_data: HashMap<Output, OutputData>,
+ mouse_down: bool,
+ },
+}
+
+pub struct OutputData {
+ size: Size<i32, Physical>,
+ scale: i32,
+ transform: Transform,
+ texture: GlesTexture,
+ texture_buffer: TextureBuffer<GlesTexture>,
+ buffers: [SolidColorBuffer; 8],
+ locations: [Point<i32, Physical>; 8],
+}
+
+niri_render_elements! {
+ ScreenshotUiRenderElement => {
+ Screenshot = PrimaryGpuTextureRenderElement,
+ SolidColor = SolidColorRenderElement,
+ }
+}
+
+impl ScreenshotUi {
+ pub fn new() -> Self {
+ Self::Closed {
+ last_selection: None,
+ }
+ }
+
+ pub fn open(
+ &mut self,
+ renderer: &GlesRenderer,
+ screenshots: HashMap<Output, GlesTexture>,
+ default_output: Output,
+ ) -> bool {
+ if screenshots.is_empty() {
+ return false;
+ }
+
+ let Self::Closed { last_selection } = self else {
+ return false;
+ };
+
+ let last_selection = last_selection
+ .take()
+ .and_then(|(weak, sel)| weak.upgrade().map(|output| (output, sel)));
+ let selection = match last_selection {
+ Some(selection) if screenshots.contains_key(&selection.0) => selection,
+ _ => {
+ let output = default_output;
+ let output_transform = output.current_transform();
+ let output_mode = output.current_mode().unwrap();
+ let size = output_transform.transform_size(output_mode.size);
+ (
+ output,
+ Rectangle::from_loc_and_size(
+ (size.w / 4, size.h / 4),
+ (size.w / 2, size.h / 2),
+ ),
+ )
+ }
+ };
+
+ let scale = selection.0.current_scale().integer_scale();
+ let selection = (
+ selection.0,
+ selection.1.loc,
+ selection.1.loc + selection.1.size - Size::from((scale, scale)),
+ );
+
+ let output_data = screenshots
+ .into_iter()
+ .map(|(output, texture)| {
+ let transform = output.current_transform();
+ let output_mode = output.current_mode().unwrap();
+ let size = transform.transform_size(output_mode.size);
+ let scale = output.current_scale().integer_scale();
+ let texture_buffer = TextureBuffer::from_texture(
+ renderer,
+ texture.clone(),
+ scale,
+ Transform::Normal,
+