From 365f1a6b379a46d29108b7a50dbca465dfcde92c Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Tue, 28 Jul 2026 14:22:30 -0500 Subject: [PATCH] terminal: add visibility reports Allow terminal applications to detect when their view is not observable so they can avoid unnecessary rendering work. Implement mode 2033 and the visibility status query, and report changes from tab, pane, window, and platform occlusion state. --- docs/changelog.rst | 2 ++ kitty/glfw.c | 12 ++++++++++-- kitty/modes.h | 3 +++ kitty/screen.c | 31 ++++++++++++++++++++++++++++++ kitty/screen.h | 4 +++- kitty/state.c | 44 +++++++++++++++++++++++++++++++++++++++++++ kitty/state.h | 3 +++ kitty_tests/parser.py | 11 +++++++++++ kitty_tests/screen.py | 18 ++++++++++++++++++ 9 files changed, 125 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b31e876dd..a9904c124 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -193,6 +193,8 @@ Detailed list of changes - Desktop file chooser integration: when the specified directory does not exist, open file chooser at home directory (:iss:`10292`) +- Add support for `terminal visibility reports `__ + 0.48.1 [2026-07-24] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/glfw.c b/kitty/glfw.c index 58fd1e922..877eee498 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -334,6 +334,7 @@ window_occlusion_callback(GLFWwindow *window, bool occluded) { if (!set_callback_window(window)) return; debug("OSWindow %llu occlusion state changed, occluded: %d\n", global_state.callback_os_window->id, occluded); if (!occluded) global_state.check_for_active_animated_images = true; + update_os_window_visibility_reports(global_state.callback_os_window); request_tick_callback(); global_state.callback_os_window = NULL; } @@ -342,6 +343,7 @@ static void window_iconify_callback(GLFWwindow *window, int iconified) { if (!set_callback_window(window)) return; if (!iconified) global_state.check_for_active_animated_images = true; + update_os_window_visibility_reports(global_state.callback_os_window); request_tick_callback(); global_state.callback_os_window = NULL; } @@ -633,6 +635,7 @@ set_os_window_visibility(OSWindow *w, int set_visible, bool move_to_active_scree w->keep_rendering_till_swap = 256; // try this many times request_tick_callback(); } else glfwHideWindow(w->handle); + update_os_window_visibility_reports(w); } static void @@ -1456,6 +1459,7 @@ change_state_for_os_window(OSWindow *w, int state) { case WINDOW_HIDDEN: glfwHideWindow(w->handle); break; } + update_os_window_visibility_reports(w); } #ifdef __APPLE__ @@ -2562,15 +2566,19 @@ wakeup_main_loop(void) { } bool -should_os_window_be_rendered(OSWindow* w) { +is_os_window_potentially_visible(OSWindow* w) { return ( glfwGetWindowAttrib(w->handle, GLFW_ICONIFIED) || !glfwGetWindowAttrib(w->handle, GLFW_VISIBLE) || glfwGetWindowAttrib(w->handle, GLFW_OCCLUDED) - || !glfwAreSwapsAllowed(w->handle) ) ? false : true; } +bool +should_os_window_be_rendered(OSWindow* w) { + return is_os_window_potentially_visible(w) && glfwAreSwapsAllowed(w->handle); +} + static PyObject* primary_monitor_size(PYNOARG) { GLFWmonitor* monitor = glfwGetPrimaryMonitor(); diff --git a/kitty/modes.h b/kitty/modes.h index f86a2c984..6b02dc981 100644 --- a/kitty/modes.h +++ b/kitty/modes.h @@ -88,6 +88,9 @@ // Notification of color preference change #define COLOR_PREFERENCE_NOTIFICATION (2031 << 5) +// Terminal visibility reports +#define VISIBILITY_REPORTS (2033 << 5) + // In-band resize notification mode #define INBAND_RESIZE_NOTIFICATION (2048 << 5) diff --git a/kitty/screen.c b/kitty/screen.c index 8d5aeb565..de40a8a68 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -1524,6 +1524,27 @@ write_escape_code_to_child(Screen *self, unsigned char which, const char *data) return written; } +static void +send_visibility_report(Screen *self, bool potentially_visible) { + write_escape_code_to_child(self, ESC_CSI, potentially_visible ? "?999;1n" : "?999;2n"); +} + +void +screen_visibility_changed(Screen *self, bool potentially_visible) { + const uint8_t state = potentially_visible ? 1 : 2; + if (self->visibility_state != state) { + self->visibility_state = state; + if (self->modes.mVISIBILITY_REPORTS) send_visibility_report(self, potentially_visible); + } +} + +static void +report_current_visibility(Screen *self) { + const bool potentially_visible = is_kitty_window_visible(self->window_id); + self->visibility_state = potentially_visible ? 1 : 2; + send_visibility_report(self, potentially_visible); +} + static bool write_escape_code_to_child_python(Screen *self, unsigned char which, PyObject *data) { bool written = false; @@ -1799,6 +1820,10 @@ set_mode_from_const(Screen *self, unsigned int mode, bool val) { self->modes.mINBAND_RESIZE_NOTIFICATION = val; if (val) CALLBACK("notify_child_of_resize", NULL); break; + case VISIBILITY_REPORTS: + self->modes.mVISIBILITY_REPORTS = val; + if (val) report_current_visibility(self); + break; default: private = mode >= 1 << 5; if (private) mode >>= 5; @@ -2341,6 +2366,7 @@ copy_specific_mode(Screen *self, unsigned int mode, const ScreenModes *src, Scre SIMPLE_MODE(COLOR_PREFERENCE_NOTIFICATION) SIMPLE_MODE(PASTE_EVENTS) SIMPLE_MODE(INBAND_RESIZE_NOTIFICATION) + SIMPLE_MODE(VISIBILITY_REPORTS) SIMPLE_MODE(DECCKM) SIMPLE_MODE(DECTCEM) SIMPLE_MODE(DECAWM) @@ -2383,6 +2409,7 @@ copy_specific_modes(Screen *self, const ScreenModes *src, ScreenModes *dest) { copy_specific_mode(self, FOCUS_TRACKING, src, dest); copy_specific_mode(self, COLOR_PREFERENCE_NOTIFICATION, src, dest); copy_specific_mode(self, INBAND_RESIZE_NOTIFICATION, src, dest); + copy_specific_mode(self, VISIBILITY_REPORTS, src, dest); copy_specific_mode(self, PASTE_EVENTS, src, dest); copy_specific_mode(self, DECCKM, src, dest); copy_specific_mode(self, DECTCEM, src, dest); @@ -2937,6 +2964,9 @@ report_device_status(Screen *self, unsigned int which, bool private) { if (private) { CALLBACK("report_color_scheme_preference", NULL); } break; + case 998: // https://rockorager.dev/misc/visibility-reports/ + if (private) report_current_visibility(self); + break; } } @@ -2961,6 +2991,7 @@ report_mode_status(Screen *self, unsigned int which, bool private) { KNOWN_MODE(BRACKETED_PASTE); KNOWN_MODE(FOCUS_TRACKING); KNOWN_MODE(COLOR_PREFERENCE_NOTIFICATION); + KNOWN_MODE(VISIBILITY_REPORTS); KNOWN_MODE(INBAND_RESIZE_NOTIFICATION); KNOWN_MODE(PASTE_EVENTS); #undef KNOWN_MODE diff --git a/kitty/screen.h b/kitty/screen.h index cddfcbb67..acb60a58a 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -26,7 +26,7 @@ typedef struct DnDCommand { typedef struct { bool mLNM, mIRM, mDECTCEM, mDECSCNM, mDECOM, mDECAWM, mDECCOLM, mDECARM, mDECCKM, mCOLOR_PREFERENCE_NOTIFICATION, mBRACKETED_PASTE, mFOCUS_TRACKING, mDECSACE, mHANDLE_TERMIOS_SIGNALS, mINBAND_RESIZE_NOTIFICATION, - mPASTE_EVENTS; + mPASTE_EVENTS, mVISIBILITY_REPORTS; MouseTrackingMode mouse_tracking_mode; MouseTrackingProtocol mouse_tracking_protocol; } ScreenModes; @@ -150,6 +150,7 @@ typedef struct { DisableLigature disable_ligatures; PyObject *marker; bool has_focus; + uint8_t visibility_state; // 0 = unknown, 1 = potentially visible, 2 = not visible bool has_activity_since_last_focus; hyperlink_id_type active_hyperlink_id; HYPERLINK_POOL_HANDLE hyperlink_pool; @@ -225,6 +226,7 @@ void screen_restore_mode(Screen *, unsigned int); void screen_save_modes(Screen *); void screen_save_mode(Screen *, unsigned int); bool write_escape_code_to_child(Screen *self, unsigned char which, const char *data); +void screen_visibility_changed(Screen *self, bool potentially_visible); void screen_cursor_position(Screen*, unsigned int, unsigned int); void screen_cursor_move(Screen *self, unsigned int count/*=1*/, int move_direction/*=-1*/, bool allow_move_to_previous_line); void screen_erase_in_line(Screen *, unsigned int, bool); diff --git a/kitty/state.c b/kitty/state.c index f35bbcad6..9f024061f 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -165,6 +165,39 @@ window_for_window_id(id_type kitty_window_id) { return NULL; } +bool +is_kitty_window_visible(id_type kitty_window_id) { + for (size_t i = 0; i < global_state.num_os_windows; i++) { + OSWindow *os_window = global_state.os_windows + i; + for (size_t t = 0; t < os_window->num_tabs; t++) { + Tab *tab = os_window->tabs + t; + for (size_t w = 0; w < tab->num_windows; w++) { + Window *window = tab->windows + w; + if (window->id == kitty_window_id) { + return is_os_window_potentially_visible(os_window) && t == os_window->active_tab && window->visible; + } + } + } + } + // If the view cannot be found, its visibility is unknown and must be + // treated conservatively as potentially visible. + return true; +} + +void +update_os_window_visibility_reports(OSWindow *os_window) { + const bool os_window_is_visible = is_os_window_potentially_visible(os_window); + for (size_t t = 0; t < os_window->num_tabs; t++) { + Tab *tab = os_window->tabs + t; + for (size_t w = 0; w < tab->num_windows; w++) { + Window *window = tab->windows + w; + if (window->render_data.screen) { + screen_visibility_changed(window->render_data.screen, os_window_is_visible && t == os_window->active_tab && window->visible); + } + } + } +} + static void free_bgimage_bitmap(BackgroundImage *bgimage) { if (!bgimage->bitmap) return; @@ -502,6 +535,10 @@ attach_window(id_type os_window_id, id_type tab_id, id_type id) { ) resize_screen(osw, w->render_data.screen, true); else screen_dirty_sprite_positions(w->render_data.screen); w->render_data.screen->reload_all_gpu_data = true; + screen_visibility_changed( + w->render_data.screen, + is_os_window_potentially_visible(osw) && t == osw->active_tab && w->visible + ); break; } } @@ -585,6 +622,7 @@ set_active_tab(id_type os_window_id, unsigned int idx) { WITH_OS_WINDOW(os_window_id) os_window->active_tab = idx; os_window->needs_render = true; + update_os_window_visibility_reports(os_window); END_WITH_OS_WINDOW } @@ -1203,6 +1241,12 @@ PYWRAP1(update_window_visibility) { bool was_visible = window->visible & 1; window->visible = visible & 1; if (!was_visible && window->visible) global_state.check_for_active_animated_images = true; + if (window->render_data.screen) { + screen_visibility_changed( + window->render_data.screen, + is_os_window_potentially_visible(osw) && t == osw->active_tab && window->visible + ); + } END_WITH_WINDOW; Py_RETURN_NONE; } diff --git a/kitty/state.h b/kitty/state.h index 86d0b5e5d..246e84ffc 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -566,6 +566,9 @@ void update_os_window_references(void); void mark_os_window_for_close(OSWindow* w, CloseRequest cr); void update_os_window_viewport(OSWindow *window, bool notify_boss); bool should_os_window_be_rendered(OSWindow* w); +bool is_os_window_potentially_visible(OSWindow* w); +bool is_kitty_window_visible(id_type kitty_window_id); +void update_os_window_visibility_reports(OSWindow *w); void wakeup_main_loop(void); bool make_window_context_current(id_type); void hide_mouse(OSWindow *w); diff --git a/kitty_tests/parser.py b/kitty_tests/parser.py index 8685621c3..964fc0f7b 100644 --- a/kitty_tests/parser.py +++ b/kitty_tests/parser.py @@ -799,6 +799,17 @@ class TestParser(BaseTest): pb('\033[?2026$p', ('report_mode_status', 2026, 1)) self.ae(c.wtcbuf, b'\x1b[?2026;2$y') + c.clear() + pb('\033[?998n', ('report_device_status', 998, 1)) + self.ae(c.wtcbuf, b'\x1b[?999;1n') + c.clear() + pb('\033[?2033h', ('screen_set_mode', 2033, 1)) + self.ae(c.wtcbuf, b'\x1b[?999;1n') + c.clear() + pb('\033[?2033$p', ('report_mode_status', 2033, 1)) + self.ae(c.wtcbuf, b'\x1b[?2033;1$y') + pb('\033[?2033l', ('screen_reset_mode', 2033, 1)) + def test_csi_code_rep(self): s = self.create_screen(8) pb = partial(self.parse_bytes_dump, s) diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index c54344865..0ba9191e5 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -404,6 +404,24 @@ class TestScreen(BaseTest): parse_bytes(s, b'\x1b[?2048h') # ] self.ae(c.num_of_resize_events, 2) + def test_visibility_reports(self): + s = self.create_screen() + c = s.callbacks + parse_bytes(s, b'\x1b[?2033$p') # ] + self.ae(c.wtcbuf, b'\x1b[?2033;2$y') # ] + c.clear() + parse_bytes(s, b'\x1b[?998n') # ] + self.ae(c.wtcbuf, b'\x1b[?999;1n') # ] + c.clear() + parse_bytes(s, b'\x1b[?2033h\x1b[?2033$p') # ]] + self.ae(c.wtcbuf, b'\x1b[?999;1n\x1b[?2033;1$y') # ]] + c.clear() + parse_bytes(s, b'\x1b[?2033h') # ] + self.ae(c.wtcbuf, b'\x1b[?999;1n') # ] + c.clear() + parse_bytes(s, b'\x1b[?2033l\x1b[?2033$p') # ]] + self.ae(c.wtcbuf, b'\x1b[?2033;2$y') # ] + def test_da1(self): s = self.create_screen() parse_bytes(s, b'\x1b[c\x1b[0c') # ]]