diff options
| author | Calvin <calvinm@pobox.com> | 2026-08-09 19:26:25 -0400 |
|---|---|---|
| committer | Calvin <calvinm@pobox.com> | 2026-08-09 19:26:25 -0400 |
| commit | 8200c261f60d0837fa553c67a668230582cfc90e (patch) | |
| tree | c687b41be126464500ad167ddd929703fc2d3b97 /i3-overview | |
Diffstat (limited to 'i3-overview')
| -rwxr-xr-x | i3-overview | 457 |
1 files changed, 457 insertions, 0 deletions
diff --git a/i3-overview b/i3-overview new file mode 100755 index 0000000..3f4f46d --- /dev/null +++ b/i3-overview @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""Expose-style overview of all i3 workspaces on the focused output. + +Click a window tile to focus it, click empty workspace space to switch +to it, Escape/click-outside to cancel. Bind to a key with: + + bindsym $mod+Tab exec --no-startup-id i3-overview +""" +import cairo +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +from gi.repository import Gdk, GLib, Gtk +import i3ipc +from Xlib import Xatom +from Xlib import display as xlib_display + +ICON_SIZE = 20 +_NET_WM_ICON = None +_xdisplay = None + + +def _get_xdisplay(): + global _xdisplay, _NET_WM_ICON + if _xdisplay is None: + _xdisplay = xlib_display.Display() + _NET_WM_ICON = _xdisplay.intern_atom("_NET_WM_ICON") + return _xdisplay + + +def fetch_window_icon(window_id, target_size=ICON_SIZE): + """Reads _NET_WM_ICON off an X window and returns a cairo ImageSurface + scaled to target_size, or None if the window has no icon set.""" + if not window_id: + return None + try: + d = _get_xdisplay() + w = d.create_resource_object("window", window_id) + prop = w.get_full_property(_NET_WM_ICON, Xatom.CARDINAL) + if prop is None or not prop.value: + return None + data = prop.value + + # _NET_WM_ICON is a concatenation of one or more (w, h, pixels...) + # blocks; pick the one closest to (but not below) our target size. + best = None + i = 0 + n = len(data) + while i + 2 <= n: + w_, h_ = int(data[i]), int(data[i + 1]) + count = w_ * h_ + if w_ <= 0 or h_ <= 0 or i + 2 + count > n: + break + block = (w_, h_, data[i + 2:i + 2 + count]) + if best is None: + best = block + else: + bw = best[0] + if (bw < target_size and w_ > bw) or ( + w_ >= target_size and w_ < bw + ): + best = block + i += 2 + count + if best is None: + return None + + iw, ih, pixels = best + stride = cairo.ImageSurface.format_stride_for_width(cairo.FORMAT_ARGB32, iw) + buf = bytearray(stride * ih) + for y in range(ih): + row_off = y * stride + src_off = y * iw + for x in range(iw): + argb = int(pixels[src_off + x]) & 0xFFFFFFFF + a = (argb >> 24) & 0xFF + r = (argb >> 16) & 0xFF + g = (argb >> 8) & 0xFF + b = argb & 0xFF + # premultiply, cairo ARGB32 native (little-endian BGRA bytes) + r = r * a // 255 + g = g * a // 255 + b = b * a // 255 + o = row_off + x * 4 + buf[o] = b + buf[o + 1] = g + buf[o + 2] = r + buf[o + 3] = a + surface = cairo.ImageSurface.create_for_data( + buf, cairo.FORMAT_ARGB32, iw, ih, stride + ) + if iw == target_size: + return surface + + scaled = cairo.ImageSurface(cairo.FORMAT_ARGB32, target_size, target_size) + cr = cairo.Context(scaled) + cr.scale(target_size / iw, target_size / ih) + cr.set_source_surface(surface, 0, 0) + cr.paint() + return scaled + except Exception: + return None + + +def fuzzy_match(query, text): + """Case-insensitive subsequence match: every char of query must appear + in text, in order, not necessarily contiguous (classic fuzzy-finder).""" + if not query: + return True + if not text: + return False + text = text.lower() + pos = 0 + for ch in query.lower(): + pos = text.find(ch, pos) + if pos == -1: + return False + pos += 1 + return True + + +MARGIN = 24 +GUTTER = 16 +WS_TITLE_H = 28 +WIN_PADDING = 6 +BG = (0.09, 0.09, 0.11, 0.92) +WS_BG = (0.15, 0.15, 0.18, 1.0) +WS_BG_FOCUSED = (0.20, 0.28, 0.38, 1.0) +WIN_BG = (0.24, 0.24, 0.28, 1.0) +WIN_BG_HOVER = (0.32, 0.45, 0.62, 1.0) +WIN_BG_FOCUSED = (0.30, 0.55, 0.85, 1.0) +WIN_BG_FLOAT = (0.30, 0.26, 0.20, 0.95) +WIN_BORDER_FLOAT = (0.85, 0.60, 0.30, 0.9) +TEXT = (0.92, 0.92, 0.94, 1.0) +TEXT_DIM = (0.65, 0.65, 0.7, 1.0) + + +class Overview(Gtk.Window): + def __init__(self, i3): + super().__init__(type=Gtk.WindowType.TOPLEVEL) + self.i3 = i3 + self.set_decorated(False) + self.set_app_paintable(True) + self.set_keep_above(True) + self.connect("draw", self.on_draw) + self.connect("button-press-event", self.on_click) + self.connect("motion-notify-event", self.on_motion) + self.connect("key-press-event", self.on_key) + self.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + + screen = self.get_screen() + visual = screen.get_rgba_visual() + if visual: + self.set_visual(visual) + + tree = i3.get_tree() + focused = tree.find_focused() + self.output = focused.workspace().ipc_data["output"] if focused else None + outputs = {o.name: o for o in i3.get_outputs() if o.active} + out = outputs.get(self.output) or next(iter(outputs.values())) + rect = out.rect + # i3 auto-floats fixed-size windows (min==max size hints); set that + # up before mapping so it never gets tiled into a workspace. + self.set_resizable(False) + self.set_size_request(rect.width, rect.height) + self.set_default_size(rect.width, rect.height) + self.set_type_hint(Gdk.WindowTypeHint.DIALOG) + self.set_skip_taskbar_hint(True) + self.set_skip_pager_hint(True) + self.move(rect.x, rect.y) + + self.workspaces = [ + w for w in tree.workspaces() if w.ipc_data.get("output") == out.name + ] + + self.hit_boxes = [] # (x, y, w, h, kind, target) + self.hover = None + self.icon_cache = {} + self.query = "" + + self.show_all() + GLib.idle_add(self.place_and_focus, rect.x, rect.y) + + def place_and_focus(self, x, y): + self.move(x, y) + self.get_window().focus(Gdk.CURRENT_TIME) + return False + + def on_key(self, _w, event): + if event.keyval == Gdk.KEY_Escape: + if self.query: + self.query = "" + self.queue_draw() + else: + Gtk.main_quit() + return True + + if event.keyval == Gdk.KEY_BackSpace: + self.query = self.query[:-1] + self.queue_draw() + return True + + if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + matches = self.matching_windows() + if matches: + self.i3.command(f'[con_id="{matches[0].id}"] focus') + Gtk.main_quit() + return True + + ch = Gdk.keyval_to_unicode(event.keyval) + if ch and ch >= 32: + self.query += chr(ch) + self.queue_draw() + return True + + return True + + def matching_windows(self): + result = [] + for ws in self.workspaces: + for con in ws.leaves(): + if fuzzy_match(self.query, con.name) or fuzzy_match( + self.query, con.window_class + ): + result.append(con) + return result + + def layout_columns(self, n): + cols = 1 + while cols * cols < n: + cols += 1 + rows = (n + cols - 1) // cols + return cols, rows + + def on_draw(self, widget, cr): + w = widget.get_allocated_width() + h = widget.get_allocated_height() + + cr.set_source_rgba(*BG) + cr.paint() + + self.hit_boxes = [] + + n = len(self.workspaces) + if n == 0: + return + + cols, rows = self.layout_columns(n) + cell_w = (w - MARGIN * 2 - GUTTER * (cols - 1)) / cols + cell_h = (h - MARGIN * 2 - GUTTER * (rows - 1)) / rows + + for idx, ws in enumerate(self.workspaces): + col = idx % cols + row = idx // cols + x = MARGIN + col * (cell_w + GUTTER) + y = MARGIN + row * (cell_h + GUTTER) + self.draw_workspace(cr, ws, x, y, cell_w, cell_h) + + if self.query: + self.draw_search_hud(cr, w, h) + + def draw_search_hud(self, cr, w, h): + label = f"/ {self.query}" + cr.select_font_face("sans-serif") + cr.set_font_size(18) + text_w = cr.text_extents(label)[2] + box_w = text_w + 32 + box_h = 40 + x = (w - box_w) / 2 + y = h - box_h - 14 + + cr.set_source_rgba(0.05, 0.05, 0.06, 0.95) + self.rounded_rect(cr, x, y, box_w, box_h, 8) + cr.fill() + cr.set_source_rgba(*WIN_BORDER_FLOAT) + self.rounded_rect(cr, x + 1, y + 1, box_w - 2, box_h - 2, 7) + cr.set_line_width(1.5) + cr.stroke() + + cr.set_source_rgba(*TEXT) + cr.move_to(x + 16, y + box_h / 2 + 6) + cr.show_text(label) + + def draw_workspace(self, cr, ws, x, y, w, h): + focused = ws.ipc_data.get("focused") or ws.ipc_data.get("visible") + cr.set_source_rgba(*(WS_BG_FOCUSED if focused else WS_BG)) + self.rounded_rect(cr, x, y, w, h, 10) + cr.fill() + + cr.set_source_rgba(*TEXT) + cr.select_font_face("sans-serif") + cr.set_font_size(15) + cr.move_to(x + 12, y + 20) + cr.show_text(ws.name) + + self.hit_boxes.append((x, y, w, WS_TITLE_H, "workspace", ws.name)) + + all_leaves = [ + c + for c in ws.leaves() + if fuzzy_match(self.query, c.name) or fuzzy_match(self.query, c.window_class) + ] + tiled = [c for c in all_leaves if c.floating in (None, "auto_off", "user_off")] + floating = [c for c in all_leaves if c not in tiled] + + inner_x = x + WIN_PADDING + inner_y = y + WS_TITLE_H + WIN_PADDING + inner_w = w - WIN_PADDING * 2 + inner_h = h - WS_TITLE_H - WIN_PADDING * 2 + + self.hit_boxes.append((x, inner_y, w, inner_h, "workspace", ws.name)) + + if inner_w <= 0 or inner_h <= 0: + return + + if tiled: + cols, rows = self.layout_columns(len(tiled)) + tw = (inner_w - GUTTER * (cols - 1) / 2) / cols + th = (inner_h - GUTTER * (rows - 1) / 2) / rows + + for idx, con in enumerate(tiled): + col = idx % cols + row = idx // cols + wx = inner_x + col * (tw + GUTTER / 2) + wy = inner_y + row * (th + GUTTER / 2) + self.draw_window(cr, con, wx, wy, tw, th) + + # Floating windows are drawn on top, scaled to their real position + # relative to the workspace rect, so they read as floating instead + # of being just another grid tile. + ws_rect = ws.ipc_data.get("rect") or {} + ws_w = ws_rect.get("width") or 1 + ws_h = ws_rect.get("height") or 1 + ws_x0 = ws_rect.get("x", 0) + ws_y0 = ws_rect.get("y", 0) + + for con in floating: + fr = con.ipc_data.get("rect") or {} + rel_x = (fr.get("x", ws_x0) - ws_x0) / ws_w + rel_y = (fr.get("y", ws_y0) - ws_y0) / ws_h + rel_w = fr.get("width", ws_w) / ws_w + rel_h = fr.get("height", ws_h) / ws_h + + fx = inner_x + rel_x * inner_w + fy = inner_y + rel_y * inner_h + fw = max(rel_w * inner_w, 24) + fh = max(rel_h * inner_h, 18) + self.draw_window(cr, con, fx, fy, fw, fh, floating=True) + + def draw_window(self, cr, con, x, y, w, h, floating=False): + if w <= 0 or h <= 0: + return + is_focused = con.focused + is_hover = self.hover == con.id + if floating: + color = WIN_BG_FOCUSED if is_focused else (WIN_BG_HOVER if is_hover else WIN_BG_FLOAT) + else: + color = WIN_BG_FOCUSED if is_focused else (WIN_BG_HOVER if is_hover else WIN_BG) + + if floating: + cr.set_source_rgba(0, 0, 0, 0.35) + self.rounded_rect(cr, x + 2, y + 3, w, h, 6) + cr.fill() + + cr.set_source_rgba(*color) + self.rounded_rect(cr, x, y, w, h, 6) + cr.fill() + + if floating: + cr.set_source_rgba(*WIN_BORDER_FLOAT) + self.rounded_rect(cr, x + 1, y + 1, w - 2, h - 2, 5) + cr.set_line_width(1.5) + cr.stroke() + + icon = self.get_icon(con) + text_x = x + 8 + if icon is not None and h > ICON_SIZE + 12 and w > ICON_SIZE + 16: + cr.save() + cr.translate(x + 8, y + 8) + cr.set_source_surface(icon, 0, 0) + cr.paint() + cr.restore() + text_x = x + 8 + ICON_SIZE + 6 + + title = con.name or con.window_class or "?" + cr.set_source_rgba(*TEXT) + cr.select_font_face("sans-serif") + cr.set_font_size(12) + self.draw_ellipsized(cr, title, x + 8, y + h / 2 + 4, w - 16) + + if con.window_class: + cr.set_source_rgba(*TEXT_DIM) + cr.set_font_size(10) + self.draw_ellipsized(cr, con.window_class, x + 8, y + h / 2 + 18, w - 16) + + self.hit_boxes.append((x, y, w, h, "window", con.id)) + + def get_icon(self, con): + wid = con.window + if wid not in self.icon_cache: + self.icon_cache[wid] = fetch_window_icon(wid) + return self.icon_cache[wid] + + def draw_ellipsized(self, cr, text, x, y, max_w): + s = text + while s and cr.text_extents(s)[2] > max_w: + s = s[:-1] + if s != text: + s = s[:-1] + "…" + cr.move_to(x, y) + cr.show_text(s) + + def rounded_rect(self, cr, x, y, w, h, r): + cr.new_sub_path() + cr.arc(x + w - r, y + r, r, -1.5708, 0) + cr.arc(x + w - r, y + h - r, r, 0, 1.5708) + cr.arc(x + r, y + h - r, r, 1.5708, 3.1416) + cr.arc(x + r, y + r, r, 3.1416, 4.7124) + cr.close_path() + + def find_hit(self, mx, my): + # windows drawn last / on top, so search in reverse for finer hits + for x, y, w, h, kind, target in reversed(self.hit_boxes): + if x <= mx <= x + w and y <= my <= y + h: + return kind, target + return None, None + + def on_motion(self, widget, event): + kind, target = self.find_hit(event.x, event.y) + new_hover = target if kind == "window" else None + if new_hover != self.hover: + self.hover = new_hover + widget.queue_draw() + + def on_click(self, widget, event): + kind, target = self.find_hit(event.x, event.y) + if kind == "window": + self.i3.command(f'[con_id="{target}"] focus') + Gtk.main_quit() + elif kind == "workspace": + self.i3.command(f'workspace "{target}"') + Gtk.main_quit() + else: + Gtk.main_quit() + + +def main(): + i3 = i3ipc.Connection() + Overview(i3) + Gtk.main() + + +if __name__ == "__main__": + main() |
