summaryrefslogtreecommitdiff
path: root/i3-overview
diff options
context:
space:
mode:
Diffstat (limited to 'i3-overview')
-rwxr-xr-xi3-overview457
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()