Skip to content

Add optional window title bars for window splits#9450

Merged
kovidgoyal merged 5 commits into
kovidgoyal:masterfrom
mcrmck:pane-title-bar
Mar 5, 2026
Merged

Add optional window title bars for window splits#9450
kovidgoyal merged 5 commits into
kovidgoyal:masterfrom
mcrmck:pane-title-bar

Conversation

@mcrmck

@mcrmck mcrmck commented Feb 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds an optional, configurable title bar displayed above or below each window when multiple windows are visible in a tab
  • Title bars are GPU-rendered using virtual Screen objects (same model as the tab bar) and automatically hidden when only a single window is visible
  • Follows up on discussion Interest Check: Per-Window Labels/Titles Visible in Splits #9448 — implements the feature as opt-in (disabled by default) per maintainer guidance

Configuration options

# Enable/disable and position (default: none)
window_title_bar none|top|bottom

# Template (same syntax as tab_title_template)
window_title_template '{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.window}{progress_percent}{title}'
active_window_title_template none

# Colors (default: none, falls back to tab bar colors)
window_title_bar_active_foreground   none
window_title_bar_active_background   none
window_title_bar_inactive_foreground none
window_title_bar_inactive_background none

# Text alignment
window_title_bar_align left|center|right

Implementation

  • C layer (state.h, state.c, child-monitor.c): Each Window struct gains a window_title_render_data field with its own VAO. Cell data is uploaded to the GPU in prepare_to_render_os_window and drawn in render_prepared_os_window.
  • Layout (layout/base.py): _apply_window_title_bars() shrinks each visible window's geometry by one cell height to make room for the title bar.
  • Rendering (window_title_bar.py): WindowTitleBarManager manages one WindowTitleBarScreen per visible window, handling template evaluation, SGR formatting, alignment, and cleanup.
  • Integration (tabs.py): Title bars update on layout changes (relayout_borders) and title changes (title_changed).
  • Custom rendering: Users can place a window_title_bar.py script in their kitty config directory with a draw_window_title(data) function, exposed as {custom} in templates (same pattern as tab bar's tab_bar.py).

Changes since initial submission

Addressed all reviewer feedback from @kovidgoyal:

  1. Renamed panewindow in all option names and internals
  2. Use foreground/background instead of fg/bg in color option names
  3. Color options use to_color_or_none, default to None, fall back to corresponding tab bar colors (active_tab_foreground, etc.) — no theme updates needed
  4. Custom Python script support via window_title_bar.py in config dir (mirrors tab_bar.py pattern)
  5. Default template includes bell and progress info using existing bell_on_tab and tab_activity_symbol options, matching the tab bar's default template

Test plan

  • Build compiles cleanly
  • All existing tests pass
  • Config options parse correctly with expected defaults
  • Manual testing: title bars appear/disappear correctly with splits, stack mode, layout switching, and window resizing

Ref: #9448

🤖 Generated with Claude Code

@kovidgoyal

kovidgoyal commented Feb 1, 2026

Copy link
Copy Markdown
Owner

Before I find time to review the actual code, just some high level comments.

  1. Options should not use pane but window as these are called windows not panes in kitty. Also to be consistent with the tab bar they should use foreground/background not fg/bg.

  2. The color options should use to_color_or_none and when none use the corresponding colors from the tab bar. They should default to None. Otherwise all the existing kitty color themes would need to be updated with these four colors.

  3. There should be a way to customise the rendering using a python script just as there is for tab bars (I dont know if this is already implemented)

  4. The default template should include bell and progress information just as the default template for the tab bar does.

@mcrmck mcrmck changed the title Add optional pane title bars for window splits Add optional window title bars for window splits Feb 1, 2026
@mcrmck

mcrmck commented Feb 1, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for your feedback.

I have pushed a commit addressing all four points:

  1. Renamed all options from pane_title_*window_title_*
  2. Color options now use foreground/background instead of fg/bg
  3. Color options use to_color_or_none, default to None, and fall back to the corresponding tab bar colors
  4. Added custom Python script support via window_title_bar.py in the config directory (same pattern as tab_bar.py)
  5. Default template now includes bell and progress info, reusing the existing bell_on_tab and tab_activity_symbol options to match the tab bar"--jq '.html_url'

mcrmck and others added 2 commits March 1, 2026 23:52
Add an optional title bar that displays above or below each window pane
when multiple windows are visible in a tab. This is similar to tmux's
pane-border-format or Terminator's pane title bars.

New configuration options:
- pane_title_bar: none/top/bottom (default: none)
- pane_title_template: f-string template (same syntax as tab_title_template)
- active_pane_title_template: override for active pane
- pane_title_bar_active_fg/bg: colors for active pane title
- pane_title_bar_inactive_fg/bg: colors for inactive pane titles
- pane_title_bar_align: left/center/right text alignment

The title bars are rendered using virtual Screen objects registered with
the GPU, following the same model as the tab bar. Title bars are
automatically hidden when only a single window is visible.

Ref: kovidgoyal#9448

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Rename all options from pane_title_* to window_title_*
- Use foreground/background instead of fg/bg in color option names
- Change color options to to_color_or_none defaulting to None,
  falling back to corresponding tab bar colors
- Add bell_symbol, activity_symbol, progress_percent template vars
  using existing bell_on_tab and tab_activity_symbol options
- Add custom script support via window_title_bar.py in config dir
  (draw_window_title function exposed as {custom} in templates)
- Update C structs, Python references, and regenerate config files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@mcrmck

mcrmck commented Mar 2, 2026

Copy link
Copy Markdown
Contributor Author

Hi @kovidgoyal — just a friendly ping on this. I've rebased onto the latest master and all tests pass. Let me know if there's anything else you'd like me to change, or if you have any concerns with the approach. Happy to wait if you're busy — just wanted to keep it on your radar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kovidgoyal

Copy link
Copy Markdown
Owner

no, it hasnt fallen off my radar. Indeed, am currently working on your
other PR.

@kovidgoyal

Copy link
Copy Markdown
Owner

OK, I reviewed the actual code. It's pretty inefficient, about what one would expect from an LLM. Here's a patch against child-monitor.c to improve the render loop.

diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c
index 4a03205af..01aface8f 100644
--- a/kitty/child-monitor.c
+++ b/kitty/child-monitor.c
@@ -787,7 +787,12 @@ prepare_to_render_os_window(OSWindow *os_window, monotonic_t now, unsigned int *
                         set_maximum_wait(OPT(cursor_trail) - now + WD.screen->cursor->position_changed_by_client_at);
                     }
                 }
-
+                // Prepare window title bar screen data for GPU
+                WindowRenderData *trd = &w->window_title_render_data;
+                if (trd->screen) {
+                    trd->screen->cursor_render_info.is_visible = false;
+                    if (send_cell_data_to_gpu(trd->vao_idx, trd->screen, os_window)) needs_render = true;
+                }
             } else {
                 if (WD.screen->cursor_render_info.render_even_when_unfocused) {
                     if (collect_cursor_info(&WD.screen->cursor_render_info, w, now, os_window)) needs_render = true;
@@ -813,12 +818,6 @@ prepare_to_render_os_window(OSWindow *os_window, monotonic_t now, unsigned int *
             if (send_cell_data_to_gpu(WD.vao_idx, WD.screen, os_window)) needs_render = true;
             if (WD.screen->start_visual_bell_at != 0) needs_render = true;
         }
-        // Prepare window title bar screen data for GPU
-        if (w->visible && w->window_title_render_data.screen) {
-            CursorRenderInfo *cri = &w->window_title_render_data.screen->cursor_render_info;
-            zero_at_ptr(cri);
-            if (send_cell_data_to_gpu(w->window_title_render_data.vao_idx, w->window_title_render_data.screen, os_window)) needs_render = true;
-        }
     }
     return needs_render || was_previously_rendered_with_layers != os_window->needs_layers;
 }
@@ -878,15 +877,9 @@ render_prepared_os_window(OSWindow *os_window, unsigned int active_window_id, co
             if (is_active_window) active_window = w;
             draw_cells(&WD, os_window, is_active_window, false, num_of_visible_windows == 1, w);
             if (WD.screen->start_visual_bell_at != 0) set_maximum_wait(ANIMATION_SAMPLE_WAIT);
-        }
-    }
-    // Draw window title bars
-    for (unsigned int i = 0; i < tab->num_windows; i++) {
-        Window *w = tab->windows + i;
-        if (w->visible && w->window_title_render_data.screen &&
-            w->window_title_render_data.geometry.right > w->window_title_render_data.geometry.left &&
-            w->window_title_render_data.geometry.bottom > w->window_title_render_data.geometry.top) {
-            draw_cells(&w->window_title_render_data, os_window, i == tab->active_window, true, false, NULL);
+            WindowRenderData *trd = &w->window_title_render_data;
+            if (trd->screen && num_visible_windows > 1 && trd->geometry.right > trd->geometry.left && trd->geometry.bottom > trd->geometry.top)
+                draw_cells(trd, os_window, i == tab->active_window, true, false, NULL);
         }
     }
     setup_os_window_for_rendering(os_window, tab, active_window, false);

Then the whole approach of managing window geometry is very flawed. Problems with it:

  1. It will break with height 1 row windows
  2. It results in two calls to set_geometry() per window and even worse, both calls are no longer no-ops when the window geometry doesnt change. This is extremely expensive as changing window geometry means sending SIGWINCH to the child process which will then proceed to redraw the entire screen (if a full screen TUI)
  3. You cannot change the python window geometry as it is used in other places in the code, for example to draw window borders/padding/margins rects in borders.py

IMO the whole layouting approach needs to be changed. Track the screen object used to render the title bar on each Window object instead of in a central manager class. Then at the start of the layout, set a property on every window object controlling if the titlebar should be present based on the option and number of visible windows in the tab being laid out. Then in Window.set_geometry set the titlebar render geometry and call set_window_render_data() with the changed geometry but keep self.geometry unchanged so that borders etc are drawn correctly. Remember to hide the title bar if the window has height of one row.

This means that the python code and the C code now have different versions of the window geometry. You need to review all the various code paths this could affect, like mouse events and see what effects it has. The whole concept makes me rather nervous, this will require very careful global review.

You also need to check what happens when the title bar is re-positioned or hidden/shown by reloading the config.

Finally, the title bar should be hidden if the template evaluates to empty or whitespace only string, rather than showing a blank bar.

Then if you are going to enable window titlebars users will want to be able to click on them, have a close button at least. And drag them around to re-order or move to another tab/OS Window like it is possible to drag tabs around. Which will lead to requests to always have the titlebar visible not just when there are more than one visible window. Whole can of worms.

- Eliminate double set_geometry() call: removed _apply_window_title_bars()
  which post-processed geometry causing expensive SIGWINCH to children
- Move title bar screen ownership to Window objects instead of central
  manager, with show_title_bar flag set during layout before do_layout()
- Window.set_geometry() now handles title bar geometry internally:
  self.geometry stays at layout-computed value (borders/padding correct),
  only C-side render data diverges via adjusted top/bottom
- Hide title bar for 1-row windows (ynum <= 1)
- Hide title bar when template evaluates to empty/whitespace
- Optimize C render loop: merge title bar GPU prep and draw into existing
  per-window loops, use trd pointer and is_visible=false, use
  num_visible_windows > 1 guard. Eliminates separate iteration passes.
- Simplify WindowTitleBarManager to thin coordinator

Note on C-side GPU prep placement: the suggested patch placed
send_cell_data_to_gpu for title bars inside the is_active_window branch
only. This caused a segfault (NULL deref in gleRunVertexSubmitImmediate)
because inactive windows' title bars had valid screen/geometry but no
GPU data uploaded, yet draw_cells was called for all visible title bars.
Moved to the per-window visibility block alongside the main window's
send_cell_data_to_gpu call so all visible title bars get GPU data
prepared. The draw loop matches the suggested patch exactly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mcrmck

mcrmck commented Mar 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — pushed a rework addressing all your feedback.

C render loop (child-monitor.c):

Applied your patch. The draw_cells merge into the per-window draw loop matches your diff exactly — trd pointer, num_visible_windows > 1 guard, single-line call, separate iteration eliminated. For the GPU prep, I adopted your improvements (trd pointer, trd->screen->cursor_render_info.is_visible = false instead of zero_at_ptr, merged into the per-window visibility block instead of a separate loop) but placed it at a slightly different nesting level. Your patch placed send_cell_data_to_gpu for title bars inside the if (is_active_window) branch. This caused a segfault — inactive windows' title bars had valid screen and geometry set from Python, but never got send_cell_data_to_gpu called, so draw_cells attempted to render from an unpopulated VAO buffer (NULL deref in gleRunVertexSubmitImmediate confirmed via lldb backtrace). I moved it to sit alongside the main window's send_cell_data_to_gpu — still inside if (w->visible && WD.screen) (so no separate loop, no redundant visibility check), but outside the active/inactive cursor branches so all visible title bars get GPU data prepared.

Layout architecture — eliminating double set_geometry() (layout/base.py, window.py):

Removed _apply_window_title_bars() entirely. Title bar screen is now owned by each Window object (_title_bar_screen), with a show_title_bar flag set during layout's __call__() before do_layout() based on the option and visible window count. Window.set_geometry() handles title bar geometry internally in a single call: self.geometry stores the original layout-computed value unchanged, only the C-side render data gets adjusted render_top/render_bottom. PTY size uses the adjusted content dimensions, so children see the correct size in one SIGWINCH. WindowTitleBarManager is reduced to a thin coordinator that calls window.update_title_bar() on each visible window.

Edge cases:

  • 1-row windows: show_tb = self.show_title_bar and new_geometry.ynum > 1 — no title bar when there's no room
  • Empty/whitespace template: render() returns the evaluated title string; if empty or whitespace-only, title bar geometry is zeroed to hide it
  • Config reload: works naturally — relayout() re-runs layout (resetting show_title_bar flags) and set_geometry() (which expands or contracts the content area accordingly)

Geometry divergence review:

Since Python and C now have different versions of the window geometry, I reviewed the code paths this affects:

  • self.geometry (Python) = original layout geometry → used by borders.py for border padding/margin rect calculations — unchanged, so borders draw correctly around the full window+titlebar area
  • w->render_data.geometry (C-side) = adjusted geometry with title bar carved out → used by rendering and mouse.c for hit testing via contains_mouse(). Mouse clicks in the title bar region don't dispatch to any window content since the C render geometry excludes that area. No click handling is needed for the title bar currently.
  • set_window_render_data receives the adjusted pixel coordinates but the original g.spaces values, which are padding/margin offsets relative to the content area — these remain correct since they don't depend on absolute position.

On scope creep:

Understood on the can of worms concern. I'd like to gently push back on the idea that this needs close buttons, drag-to-reorder, or click handling to be useful. The primary inspiration here is tmux-style pane identification — a passive label showing which pane is which in a split. tmux's pane title bars aren't clickable or draggable and they serve their purpose well. Similarly, I don't think showing the title bar for a single window is necessarily bad if a user explicitly enables the feature, but the current num_visible > 1 default seems reasonable.

As for tab dragging — to my knowledge tabs aren't draggable by default today either, so I don't think title bars need to set a higher bar than what tabs currently offer. If there is a config option I am missing, please let me know and I am happy to take a look at relevance here.

That said, you're the project owner and I'm happy to defer to your guidance on scope. I believe this rework provides a solid foundation for the basics and we can build from here in whatever direction you think is right.

@kovidgoyal

Copy link
Copy Markdown
Owner

Tab dragging was added to master a few weeks ago see
tab_bar_drag_threshold (it is on by default).

I suggest another config option similar to tab_bar_min_tabs
that allows users to control when the title bars show up. 1 means always
show, 0 means never show. 2 means show if at least 2 windows visible and so on.
Then the title bar position option can probably lose the "none" case and
be purely to control position. Also probably best to have only empty
title strings hide the bar rather than whitespace only. That allows
people to have blank bars is they want, while still turning off bars
for individual windows based on arbitrary criteria.

While your motivation is replicating tmux bars, I am 100% certain other
people are going to want to make the bars interactive. That said, I dont
think those use cases need to be addressed in this PR.

- Add window_title_bar_min_windows (0=never, 1=always, 2+=threshold)
  similar to tab_bar_min_tabs, to control when title bars appear
- Remove 'none' choice from window_title_bar so it purely controls
  position (top/bottom); disabling is now via min_windows 0
- Only hide title bar for truly empty template strings, not
  whitespace-only, so users can have intentionally blank bars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mcrmck

mcrmck commented Mar 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed f2ae5d0 addressing your latest feedback.

  • Added window_title_bar_min_windows option (0=never, 1=always, 2+=threshold), mirroring tab_bar_min_tabs
  • Removed none from window_title_bar choices - it now purely controls position (top/bottom), with visibility governed by window_title_bar_min_windows
  • Changed empty template check to only hide on truly empty strings, not whitespace-only - so users can have intentinoally blank bars while still being able to hide per-window via template logic.

Thank you also for the helpful context on interactive bars. If draggable tabs are already implemented, I think that gives more motivation to make window title bars draggable if you'd like. The other main interactive feature I would like to implement (for both window titles and tabs, if not already implemented) is a double click to rename. Do you have thoughts on this?

Cheers.

@kovidgoyal

Copy link
Copy Markdown
Owner

Yes draggable window title bars would be an important feature, possibly
with a mappable action that when triggerred shows the title bars and
then auto hides them after the drag operation is completed, so that
people that want to re-arrange windows can do so without permanently
enabling the title bars. However, this belongs in a separate PR after
this one is merged. It will require per layout code to translat drop
locations into positions in the layout.

As for double click to rename, sure, I have no objections, can be
implemented very easily using an overlay window.

@kovidgoyal
kovidgoyal merged commit b66703e into kovidgoyal:master Mar 5, 2026
14 checks passed
@mcrmck mcrmck mentioned this pull request Mar 7, 2026
mcrmck added a commit to mcrmck/kitty that referenced this pull request Mar 9, 2026
Implements drag-to-reorder for window title bars, following up on the
merged window title bar feature (kovidgoyal#9450) and the design discussion in kovidgoyal#9619.

- Drag a title bar and drop on another title bar to swap positions
- Drop on a window body quadrant (left/right/top/bottom) to insert as
  a directional split; Splits layout uses insert_window_next_to(), other
  layouts fall back to move_window_to_group()
- Drop on a tab bar tab to move the window into that tab
- Drop on another OS window to move into its active tab
- Drop outside kitty to detach into a new OS window
- Tab bar highlights the hovered tab during a window drag, mirroring
  how the destination window title bar is highlighted
- toggle_window_title_bars action temporarily force-shows title bars
  for drag-to-reorder when they are normally hidden, auto-hiding after
  the drag completes
- window_title_bar_drag_threshold option (default 5px) controls how far
  the mouse must move before a drag is initiated; 0 disables dragging

MIME type follows the same convention as tab dragging:
application/net.kovidgoyal.kitty-window-{PID}

Ref: kovidgoyal#9619
@mcrmck mcrmck mentioned this pull request Mar 9, 2026
9 tasks
zchee pushed a commit to zchee/kitty that referenced this pull request Jul 8, 2026
Implements drag-to-reorder for window title bars, following up on the
merged window title bar feature (kovidgoyal#9450) and the design discussion in kovidgoyal#9619.

- Drag a title bar and drop on another title bar to swap positions
- Drop on a window body quadrant (left/right/top/bottom) to insert as
  a directional split; Splits layout uses insert_window_next_to(), other
  layouts fall back to move_window_to_group()
- Drop on a tab bar tab to move the window into that tab
- Drop on another OS window to move into its active tab
- Drop outside kitty to detach into a new OS window
- Tab bar highlights the hovered tab during a window drag, mirroring
  how the destination window title bar is highlighted
- toggle_window_title_bars action temporarily force-shows title bars
  for drag-to-reorder when they are normally hidden, auto-hiding after
  the drag completes
- window_title_bar_drag_threshold option (default 5px) controls how far
  the mouse must move before a drag is initiated; 0 disables dragging

MIME type follows the same convention as tab dragging:
application/net.kovidgoyal.kitty-window-{PID}

Ref: kovidgoyal#9619
bcmyguest added a commit to bcmyguest/config that referenced this pull request Jul 16, 2026
kitty >= 0.47 (PR kovidgoyal/kitty#9450) draws an optional title bar on
each split. Enable it for tabs with 2+ splits and prefix the title with
the split's number via a {custom} hook (window_title_bar.py): the hook
reads the window's group index from iter_windows_with_number, the same
numbering nth_window uses, so the displayed number is exactly what
alt+N jumps to.

Assisted-by: Claude:claude-fable-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants