Skip to content
Merged
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
22 changes: 12 additions & 10 deletions src/app/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,18 @@ pub fn close_window(handle: &WindowHandle<Root>, cx: &mut App) {
/// Fetch open windows from the compositor and convert to WindowItems.
fn fetch_windows(compositor: &dyn Compositor) -> Vec<WindowItem> {
match compositor.list_windows() {
Ok(windows) => {
windows
.into_iter()
.map(|info| {
// Try to resolve icon from app class
let icon_path = resolve_window_icon(&info.class);
WindowItem::from_window_info(info, icon_path)
})
.collect()
}
Ok(windows) => windows
.into_iter()
.map(|info| {
// Only resolve icon from class if compositor didn't provide icon data
let icon_path = if info.icon_data.is_some() {
None
} else {
resolve_window_icon(&info.class)
};
WindowItem::from_window_info(info, icon_path)
})
.collect(),
Err(e) => {
warn!(%e, "Failed to list windows");
Vec::new()
Expand Down
2 changes: 2 additions & 0 deletions src/compositor/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,15 @@ mod tests {
class: "firefox".to_string(),
workspace: 1,
focused: false,
icon_data: None,
},
WindowInfo {
address: "2".to_string(),
title: "Launcher".to_string(),
class: "zlaunch".to_string(),
workspace: 1,
focused: true,
icon_data: None,
},
];

Expand Down
1 change: 1 addition & 0 deletions src/compositor/hyprland.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ impl Compositor for HyprlandCompositor {
class: c.class,
workspace,
focused,
icon_data: None,
}
})
.collect();
Expand Down
153 changes: 129 additions & 24 deletions src/compositor/kwin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
use super::base::CompositorCapabilities;
use super::{Compositor, WindowInfo};
use anyhow::{Context, Result};
use std::collections::HashMap;
use image::{ImageBuffer, ImageFormat, Rgba};
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use std::process::Command;
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::OwnedValue;
use zbus::zvariant::{OwnedValue, Structure, Value};

/// Type alias for KRunner match results from WindowsRunner.Match D-Bus call.
/// Tuple: (match_id, text, subtext, type, relevance, properties)
Expand All @@ -28,6 +30,95 @@ pub struct KwinCompositor {
connection: Connection,
}

/// Parse icon-data from KRunner and return as PNG bytes.
///
/// The icon-data format is a D-Bus structure: (iiibiiay)
/// - i32: width
/// - i32: height
/// - i32: rowstride (bytes per row)
/// - bool: has_alpha
/// - i32: bits_per_sample (usually 8)
/// - i32: channels (3 for RGB, 4 for RGBA)
/// - Vec<u8>: pixel data
fn parse_icon_data(icon_data: &OwnedValue) -> Option<Vec<u8>> {
// Try to extract the structure
let structure: &Structure = icon_data.downcast_ref().ok()?;
let fields = structure.fields();

if fields.len() < 7 {
return None;
}

let width: i32 = fields[0].downcast_ref::<i32>().ok()?;
let height: i32 = fields[1].downcast_ref::<i32>().ok()?;
let rowstride: i32 = fields[2].downcast_ref::<i32>().ok()?;
let has_alpha: bool = fields[3].downcast_ref::<bool>().ok()?;
let _bits_per_sample: i32 = fields[4].downcast_ref::<i32>().ok()?;
let channels: i32 = fields[5].downcast_ref::<i32>().ok()?;

// Validate dimensions and channels before any arithmetic
if width <= 0 || height <= 0 || width > 256 || height > 256 {
return None;
}
if rowstride <= 0 || channels < 3 || channels > 4 {
return None;
}

// Get the pixel data array
let pixel_data: Vec<u8> = match &fields[6] {
Value::Array(arr) => arr
.iter()
.filter_map(|v| v.downcast_ref::<u8>().ok())
.collect(),
_ => return None,
};

let width = width as u32;
let height = height as u32;
let rowstride = rowstride as usize;
let channels = channels as usize;

// Create RGBA image buffer
let mut img: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(width, height);

for y in 0..height {
for x in 0..width {
// Use checked arithmetic to avoid overflow
let row_offset = (y as usize).checked_mul(rowstride)?;
let col_offset = (x as usize).checked_mul(channels)?;
let src_offset = row_offset.checked_add(col_offset)?;

// Use safe array access with .get()
let pixel = if channels == 4 && has_alpha {
Rgba([
*pixel_data.get(src_offset)?,
*pixel_data.get(src_offset + 1)?,
*pixel_data.get(src_offset + 2)?,
*pixel_data.get(src_offset + 3)?,
])
} else if channels == 3 {
Rgba([
*pixel_data.get(src_offset)?,
*pixel_data.get(src_offset + 1)?,
*pixel_data.get(src_offset + 2)?,
255,
])
} else {
return None;
};

img.put_pixel(x, y, pixel);
}
}

// Encode as PNG in memory
let mut png_bytes = Vec::new();
let mut cursor = Cursor::new(&mut png_bytes);
img.write_to(&mut cursor, ImageFormat::Png).ok()?;

Some(png_bytes)
}

impl KwinCompositor {
/// Create a new KWin compositor client.
///
Expand Down Expand Up @@ -67,30 +158,44 @@ impl KwinCompositor {
.call("Match", &("",))
.context("Failed to call WindowsRunner.Match")?;

// Track seen window IDs to deduplicate (KRunner returns multiple actions per window)
let mut seen_ids: HashSet<String> = HashSet::new();

let windows: Vec<WindowInfo> = result
.into_iter()
.map(
|(match_id, title, _subtext, _type_id, _relevance, _props)| {
// match_id format: "0_{uuid}" - extract the window ID
// The "0_" prefix indicates action index (0 = activate)
let window_id = match_id
.strip_prefix("0_")
.map(|s| s.to_string())
.unwrap_or_else(|| match_id.clone());

// Try to extract app class from the title (often "Title - AppName")
// This is a heuristic - the actual class isn't directly available
let class = title.rsplit(" - ").next().unwrap_or(&title).to_string();

WindowInfo {
address: window_id,
title: title.clone(),
class,
workspace: 1, // WindowsRunner doesn't expose workspace info
focused: false, // We can't easily determine this from krunner
}
},
)
.filter_map(|(match_id, title, _subtext, _type_id, _relevance, props)| {
// match_id format: "{action_index}_{uuid}" - extract the window ID
// Action indices: 0 = activate, 1 = close, 8 = switch to desktop, etc.
// Only keep action 0 (activate) entries to avoid duplicates
let window_id = match_id.strip_prefix("0_")?;

// Skip if we've already seen this window
if !seen_ids.insert(window_id.to_string()) {
return None;
}

// Try to parse icon-data as PNG bytes if available
let icon_data = props
.get("icon-data")
.and_then(|data| parse_icon_data(data));

// Use icon name for class if available, otherwise extract from title
let icon_name = props
.get("icon")
.and_then(|v| TryInto::<String>::try_into(v.clone()).ok());

let class = icon_name
.unwrap_or_else(|| title.rsplit(" - ").next().unwrap_or(&title).to_string());

Some(WindowInfo {
address: window_id.to_string(),
title: title.clone(),
class,
workspace: 1, // WindowsRunner doesn't expose workspace info
focused: false, // We can't easily determine this from krunner
icon_data,
})
})
.collect();

Ok(windows)
Expand Down
2 changes: 2 additions & 0 deletions src/compositor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ pub struct WindowInfo {
pub workspace: i32,
/// Whether this window is currently focused
pub focused: bool,
/// Optional icon as PNG bytes (used when compositor provides icon data directly)
pub icon_data: Option<Vec<u8>>,
}

/// Trait for compositor window management operations.
Expand Down
1 change: 1 addition & 0 deletions src/compositor/niri.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ impl Compositor for NiriCompositor {
class: window.app_id,
workspace: window.workspace_id as i32,
focused: window.is_focused,
icon_data: None,
});
}

Expand Down
7 changes: 6 additions & 1 deletion src/items/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ pub struct WindowItem {
pub app_name: String,
/// Pre-computed description (e.g., "Firefox - Workspace 2")
pub description: String,
/// Resolved icon path
/// Resolved icon path (from freedesktop icon lookup)
pub icon_path: Option<PathBuf>,
/// Raw icon data as PNG bytes (from compositor)
pub icon_data: Option<Vec<u8>>,
/// Workspace number
pub workspace: i32,
/// Whether this window is currently focused
Expand All @@ -37,6 +39,7 @@ impl WindowItem {
app_id: String,
app_name: String,
icon_path: Option<PathBuf>,
icon_data: Option<Vec<u8>>,
workspace: i32,
focused: bool,
) -> Self {
Expand All @@ -49,6 +52,7 @@ impl WindowItem {
app_name,
description,
icon_path,
icon_data,
workspace,
focused,
}
Expand All @@ -66,6 +70,7 @@ impl WindowItem {
app_name,
description,
icon_path,
icon_data: info.icon_data,
workspace: info.workspace,
focused: info.focused,
}
Expand Down
5 changes: 3 additions & 2 deletions src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ pub fn mock_window(title: &str, app_id: &str) -> WindowItem {
title.to_string(),
app_id.to_string(),
app_id.to_string(), // app_name same as app_id for simplicity
None,
1, // workspace
None, // icon_path
None, // icon_data
1, // workspace
false,
)
}
Expand Down
12 changes: 12 additions & 0 deletions src/ui/components/list_item.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::ui::theme::theme;
use gpui::{Div, ElementId, SharedString, Stateful, div, img, prelude::*, px};
use std::path::PathBuf;
use std::sync::Arc;

/// A standard list item component with icon, title, description, and action indicator.
///
Expand Down Expand Up @@ -29,12 +30,22 @@ pub struct ListItemComponent {
pub enum Icon {
/// Path to an image file
Path(PathBuf),
/// In-memory PNG image data
Data(Arc<gpui::Image>),
/// Named Phosphor icon
Named(String),
/// Custom placeholder text
Placeholder(String),
}

impl Icon {
/// Create an icon from PNG bytes
pub fn from_png_bytes(bytes: Vec<u8>) -> Self {
let image = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, bytes));
Icon::Data(image)
}
}

impl ListItemComponent {
/// Create a new list item component
pub fn new(row: usize, selected: bool) -> Self {
Expand Down Expand Up @@ -154,6 +165,7 @@ fn render_icon_element(icon: Icon) -> Div {
render_placeholder_icon(icon_container, "?")
}
}
Icon::Data(image) => icon_container.child(img(image).w(size).h(size).rounded_sm()),
Icon::Named(_name) => {
// For named icons, we'd typically use an icon library
// For now, use placeholder
Expand Down
29 changes: 27 additions & 2 deletions src/ui/views/item_rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
use crate::assets::PhosphorIcon;
use crate::items::{DisplayItem, IconProvider, ListItem};
use crate::ui::theme::theme;
use gpui::{Div, ElementId, SharedString, Stateful, div, img, prelude::*, px, svg};
use gpui::{Div, ElementId, ImageFormat, SharedString, Stateful, div, img, prelude::*, px, svg};
use std::path::PathBuf;
use std::sync::Arc;

/// Render any list item based on its type.
/// This is the main dispatch function for item rendering.
Expand Down Expand Up @@ -47,8 +48,15 @@ fn render_application(

/// Render a window item.
fn render_window(win: &crate::items::WindowItem, selected: bool, row: usize) -> Stateful<Div> {
// Use in-memory icon data if available, otherwise fall back to icon path
let icon = if let Some(ref data) = win.icon_data {
render_icon_from_data(data)
} else {
render_icon(win.icon_path.as_ref())
};

let mut item = item_container(row, selected)
.child(render_icon(win.icon_path.as_ref()))
.child(icon)
.child(render_text_content(
&win.title,
Some(&win.description),
Expand Down Expand Up @@ -258,6 +266,23 @@ pub fn item_container(row: usize, selected: bool) -> Stateful<Div> {
.gap_2()
}

/// Render an icon from PNG bytes in memory.
fn render_icon_from_data(data: &[u8]) -> Div {
let theme = theme();
let size = theme.icon_size;

let icon_container = div()
.w(size)
.h(size)
.flex_shrink_0()
.flex()
.items_center()
.justify_center();

let image = Arc::new(gpui::Image::from_bytes(ImageFormat::Png, data.to_vec()));
icon_container.child(img(image).w(size).h(size).rounded_sm())
}

/// Render an icon from a file path, with fallback placeholder.
pub fn render_icon(icon_path: Option<&PathBuf>) -> Div {
let theme = theme();
Expand Down