Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::{
keyboard_layout::KeyboardLayout,
keyboard_submap::KeyboardSubmap,
media_player::MediaPlayer,
minimap::Minimap,
notifications::Notifications,
privacy::Privacy,
settings::{self, Settings, audio},
Expand Down Expand Up @@ -61,6 +62,7 @@ pub struct App {
pub updates: Option<Updates>,
pub workspaces: Workspaces,
pub window_title: WindowTitle,
pub minimap: Minimap,
pub system_info: SystemInfo,
pub keyboard_layout: KeyboardLayout,
pub keyboard_submap: KeyboardSubmap,
Expand All @@ -74,6 +76,11 @@ pub struct App {
pub visible: bool,
}

// App::Message is an aggregator enum dispatching to many sub-modules. Sub-
// messages naturally vary in size (network/bluetooth service events are
// large; transient unit variants are tiny). Boxing every "big" variant would
// just shift verbosity to call sites without meaningful runtime benefit.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
pub enum Message {
ConfigChanged(Box<Config>),
Expand All @@ -83,6 +90,7 @@ pub enum Message {
Updates(modules::updates::Message),
Workspaces(modules::workspaces::Message),
WindowTitle(modules::window_title::Message),
Minimap(modules::minimap::Message),
SystemInfo(modules::system_info::Message),
KeyboardLayout(modules::keyboard_layout::Message),
KeyboardSubmap(modules::keyboard_submap::Message),
Expand Down Expand Up @@ -144,6 +152,7 @@ impl App {
updates: config.updates.map(Updates::new),
workspaces: Workspaces::new(config.workspaces),
window_title: WindowTitle::new(config.window_title),
minimap: Minimap::new(),
system_info: SystemInfo::new(config.system_info),
keyboard_layout: KeyboardLayout::new(config.keyboard_layout),
keyboard_submap: KeyboardSubmap::default(),
Expand Down Expand Up @@ -418,6 +427,10 @@ impl App {
self.window_title.update(msg);
Task::none()
}
Message::Minimap(msg) => {
self.minimap.update(msg);
Task::none()
}
Message::SystemInfo(msg) => {
self.system_info.update(msg);
Task::none()
Expand Down
2 changes: 2 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,7 @@ pub enum ModuleName {
Updates,
Workspaces,
WindowTitle,
Minimap,
SystemInfo,
KeyboardLayout,
KeyboardSubmap,
Expand Down Expand Up @@ -1038,6 +1039,7 @@ impl<'de> Deserialize<'de> for ModuleName {
"Updates" => ModuleName::Updates,
"Workspaces" => ModuleName::Workspaces,
"WindowTitle" => ModuleName::WindowTitle,
"Minimap" => ModuleName::Minimap,
"SystemInfo" => ModuleName::SystemInfo,
"KeyboardLayout" => ModuleName::KeyboardLayout,
"KeyboardSubmap" => ModuleName::KeyboardSubmap,
Expand Down
257 changes: 257 additions & 0 deletions src/modules/minimap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
use crate::services::{
ReadOnlyService, ServiceEvent,
compositor::{CompositorService, CompositorWindow},
};
use iced::{
Alignment, Color, Element, Length, Point, Rectangle, Renderer, Size, Subscription, Theme,
mouse::Cursor,
widget::{
canvas,
canvas::{Frame, Geometry, Program},
container,
},
};
use std::collections::BTreeMap;

// Target minimap size in pixels; the layout is scaled to fit within it.
const MAX_H: f32 = 16.0;
const MAX_W: f32 = 80.0;
const MIN_TILE: f32 = 2.0;
// Gap carved out of a tile's edge where it abuts a neighbour, so adjacent
// same-colour tiles don't merge into one block.
const TILE_GAP: f32 = 1.0;
// Tolerance for "shared edge" — coordinates come from compositor floats, so
// allow sub-pixel slack.
const EPS: f32 = 0.5;

/// An axis-aligned rectangle `(x, y, w, h)` in some 2D space.
type Rect = (f32, f32, f32, f32);

#[derive(Debug, Clone)]
pub enum Message {
ServiceEvent(ServiceEvent<CompositorService>),
}

pub struct Minimap {
service: Option<CompositorService>,
}

impl Minimap {
pub fn new() -> Self {
Self { service: None }
}

pub fn update(&mut self, message: Message) {
match message {
Message::ServiceEvent(event) => match event {
ServiceEvent::Init(service) => {
self.service = Some(service);
}
ServiceEvent::Update(event) => {
if let Some(service) = &mut self.service {
service.update(event);
}
}
_ => {}
},
}
}

pub fn view(&self) -> Option<Element<'_, Message>> {
let service = self.service.as_ref()?;
let active_id = service.active_workspace_id?;
let windows: Vec<&CompositorWindow> = service
.windows
.iter()
.filter(|w| w.workspace_id == Some(active_id))
.collect();

let minimap = build_canvas(&windows)?;

let (w, h) = (minimap.width, minimap.height);
Some(
container(
canvas(minimap)
.width(Length::Fixed(w))
.height(Length::Fixed(h)),
)
.align_y(Alignment::Center)
.into(),
)
}

pub fn subscription(&self) -> Subscription<Message> {
CompositorService::subscribe().map(Message::ServiceEvent)
}
}

#[derive(Debug, Clone, Copy)]
enum TileRole {
Normal,
Focused,
Urgent,
}

fn role_of(w: &CompositorWindow) -> TileRole {
if w.is_focused {
TileRole::Focused
} else if w.is_urgent {
TileRole::Urgent
} else {
TileRole::Normal
}
}

fn tile_color(role: TileRole, theme: &Theme) -> Color {
match role {
TileRole::Focused => theme.palette().primary,
TileRole::Urgent => theme.palette().danger,
TileRole::Normal => theme.extended_palette().background.strong.color,
}
}

#[derive(Debug, Clone, Copy)]
struct CanvasTile {
x: f32,
y: f32,
w: f32,
h: f32,
inset_r: f32,
inset_b: f32,
role: TileRole,
}

/// A fully positioned minimap, in canvas (post-scale) pixel coordinates.
struct MinimapCanvas {
tiles: Vec<CanvasTile>,
width: f32,
height: f32,
}

impl<Message> Program<Message> for MinimapCanvas {
type State = ();

fn draw(
&self,
_state: &Self::State,
renderer: &Renderer,
theme: &Theme,
bounds: Rectangle,
_cursor: Cursor,
) -> Vec<Geometry> {
let mut frame = Frame::new(renderer, bounds.size());

// Tiled windows. Insets shrink a tile only on sides with a neighbour,
// so adjacent tiles stay separated while outer edges stay flush.
for t in &self.tiles {
frame.fill_rectangle(
Point::new(t.x, t.y),
Size::new(
(t.w - t.inset_r).max(MIN_TILE),
(t.h - t.inset_b).max(MIN_TILE),
),
tile_color(t.role, theme),
);
}

vec![frame.into_geometry()]
}
}

fn has_neighbor_right(rects: &[Rect], x: f32, y: f32, w: f32, h: f32) -> bool {
rects
.iter()
.any(|&(ox, oy, _, oh)| (ox - (x + w)).abs() < EPS && oy < y + h - EPS && oy + oh > y + EPS)
}

fn has_neighbor_below(rects: &[Rect], x: f32, y: f32, w: f32, h: f32) -> bool {
rects
.iter()
.any(|&(ox, oy, ow, _)| (oy - (y + h)).abs() < EPS && ox < x + w - EPS && ox + ow > x + EPS)
}

/// Lay tiled windows out in workspace-layout space (column 1 at x = 0):
/// columns left to right by cumulative max width, tiles top to bottom within
/// a column by cumulative height. Positions are derived from the grid index.
fn layout_tiled<'a>(tiled: &[&'a CompositorWindow]) -> Vec<(Rect, &'a CompositorWindow)> {
let mut by_col: BTreeMap<u32, Vec<&CompositorWindow>> = BTreeMap::new();
for w in tiled {
let (col, _) = w.tile_position.unwrap_or((0, 0));
by_col.entry(col).or_default().push(w);
}
for tiles in by_col.values_mut() {
tiles.sort_by_key(|w| w.tile_position.map(|(_, r)| r).unwrap_or(0));
}

let mut placed = Vec::new();
let mut x = 0.0_f32;
for tiles in by_col.values() {
let col_w = tiles
.iter()
.map(|w| w.tile_width)
.fold(0.0_f32, f32::max)
.max(1.0);
let mut y = 0.0_f32;
for w in tiles {
let h = w.tile_height.max(1.0);
placed.push(((x, y, col_w, h), *w));
y += h;
}
x += col_w;
}
placed
}

/// Build the minimap from the workspace's tiled windows, laid out by grid
/// index and scaled to fit. Floating windows have no grid position and aren't
/// rendered; showing them may be explored later, once niri exposes enough
/// information to place them. Returns `None` when there are no tiled windows
/// to draw.
fn build_canvas(windows: &[&CompositorWindow]) -> Option<MinimapCanvas> {
let tiled: Vec<&CompositorWindow> =
windows.iter().copied().filter(|w| !w.is_floating).collect();
let placed = layout_tiled(&tiled);
if placed.is_empty() {
return None;
}

let rects: Vec<Rect> = placed.iter().map(|(r, _)| *r).collect();
let layout_w = rects
.iter()
.map(|r| r.0 + r.2)
.fold(0.0_f32, f32::max)
.max(1.0);
let layout_h = rects
.iter()
.map(|r| r.1 + r.3)
.fold(0.0_f32, f32::max)
.max(1.0);
let scale = (MAX_H / layout_h).min(MAX_W / layout_w);

let tiles: Vec<CanvasTile> = placed
.iter()
.map(|&((x, y, w, h), win)| CanvasTile {
x: x * scale,
y: y * scale,
w: (w * scale).max(MIN_TILE),
h: (h * scale).max(MIN_TILE),
inset_r: if has_neighbor_right(&rects, x, y, w, h) {
TILE_GAP
} else {
0.0
},
inset_b: if has_neighbor_below(&rects, x, y, w, h) {
TILE_GAP
} else {
0.0
},
role: role_of(win),
})
.collect();

Some(MinimapCanvas {
tiles,
width: (layout_w * scale).max(1.0),
height: (layout_h * scale).max(1.0),
})
}
6 changes: 6 additions & 0 deletions src/modules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod custom_module;
pub mod keyboard_layout;
pub mod keyboard_submap;
pub mod media_player;
pub mod minimap;
pub mod notifications;
pub mod privacy;
pub mod settings;
Expand Down Expand Up @@ -196,6 +197,10 @@ impl App {
None,
)
}),
ModuleName::Minimap => self
.minimap
.view()
.map(|view| (view.map(Message::Minimap), None)),
ModuleName::SystemInfo => Some((
self.system_info.view().map(Message::SystemInfo),
Some(OnModulePress::ToggleMenu(MenuType::SystemInfo)),
Expand Down Expand Up @@ -265,6 +270,7 @@ impl App {
ModuleName::WindowTitle => {
Some(self.window_title.subscription().map(Message::WindowTitle))
}
ModuleName::Minimap => Some(self.minimap.subscription().map(Message::Minimap)),
ModuleName::SystemInfo => {
Some(self.system_info.subscription().map(Message::SystemInfo))
}
Expand Down
6 changes: 3 additions & 3 deletions src/modules/window_title.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use iced::{

#[derive(Debug, Clone)]
pub enum Message {
ServiceEvent(Box<ServiceEvent<CompositorService>>),
ServiceEvent(ServiceEvent<CompositorService>),
ConfigReloaded(WindowTitleConfig),
}

Expand All @@ -32,7 +32,7 @@ impl WindowTitle {

pub fn update(&mut self, message: Message) {
match message {
Message::ServiceEvent(event) => match *event {
Message::ServiceEvent(event) => match event {
ServiceEvent::Init(service) => {
self.service = Some(service);
self.recalculate_value();
Expand Down Expand Up @@ -103,6 +103,6 @@ impl WindowTitle {
}

pub fn subscription(&self) -> Subscription<Message> {
CompositorService::subscribe().map(|event| Message::ServiceEvent(Box::new(event)))
CompositorService::subscribe().map(Message::ServiceEvent)
}
}
1 change: 1 addition & 0 deletions src/services/compositor/hyprland.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ fn fetch_full_state(internal_state: &HyprInternalState) -> Result<CompositorStat
monitors,
active_workspace_id,
active_window,
windows: Vec::new(),
keyboard_layout,
submap: if internal_state.submap.is_empty() {
None
Expand Down
Loading