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') # ]]