diff --git a/docs/changelog.rst b/docs/changelog.rst index 7d99d7988..7bad40b67 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,21 @@ To update |kitty|, :doc:`follow the instructions `. Recent major new features --------------------------- +Mousing [0.46] +~~~~~~~~~~~~~~~ + +kitty already had excellent mouse support, but now it is taking it to the next +level. The kitty scrollback buffer grew support for :opt:`smooth scrolling +` and :opt:`momentum based scrolling ` +for a natural, smooth and kinetic scrolling experience. + +Additionally, you can now :opt:`drag kitty tabs around ` with the mouse +to re-order them, move them to another kitty OS Window or even detach them into +their own OS Window. + +Finally, a long requested feature, the ability to resize kitty windows (aka +splits) with the mouse was implemented. + Choose files, fast [0.45] ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -150,7 +165,49 @@ consumption to do the same tasks. Detailed list of changes ------------------------------------- -0.46.0 [future] +0.47.0 [future] +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Watchers: Add an `on_quit` event to global watchers (:iss:`9675`) + +- Wayland: Fix a crash on some compositors when dragging a tab between OS + Windows (:iss:`9677`) + +- Fix incorrect behavior when using the actions to move tab forward/backward + with a tab_bar_filter active (:iss:`9672`) + +- Prevent stacking of multiple rename tab windows (:iss:`9691`) + +- choose files kitten: Fix a regression that caused incorrect highlight of matched letters + +- macOS: When using :opt:`macos_traditional_fullscreen` do not render content under the notch (:pull:`9678`) + +0.46.1 [2026-03-16] +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- diff kitten: Highlight moved lines using a different background color (:opt:`kitten-diff.mark_moved_lines`) (:iss:`3241`) + +- Fix a regression that broke ``kitten update-self`` (:iss:`9642`) + +- macOS: Clear bell alert badge on dock icon on mouse/keyboard activity (:iss:`9640`) + +- Fix a regression that broke accept anyway shortcut in the paste confirmation dialog (:pull:`9640`) + +- Fix kitty hanging on startup on Intel macs (:iss:`9643`) + +- X11: Fix a regression that caused some high res scroll devices to be treated as line based scroll devices (:iss:`9649`) + +- Wayland: Fix momentum scrolling not working on compositors that send a stop frame with no axis information (:iss:`9653`) + +- Linux: Fix regression that broke drag and drop from GTK applications (:iss:`9656`) + +- macOS: Fix using Fn key for start dictation not working (:iss:`9661`) + +- Don't use neighboring tab colors for tab bar margins in translucent windows (:iss:`9663`) + +- macOS: Fix OS window focus not restored when switching spaces (:iss:`9665`) + +0.46.0 [2026-03-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Pixel scrolling for the kitty scrollback buffer controlled via :opt:`pixel_scroll` (:pull:`9330`) @@ -161,7 +218,7 @@ Detailed list of changes - macOS: Implement support for Apple dictation to input text in kitty (:iss:`3732`) -- Allow dragging tabs in the tab bar to re-order, move to another OS Window or +- Allow dragging tabs (opt:`tab_bar_drag_threshold`) in the tab bar to re-order, move to another OS Window or detach (:pull:`9296`) - Allow dragging window borders to resize kitty windows in all the different @@ -170,7 +227,7 @@ Detailed list of changes - Allow showing :opt:`configurable window titles ` for individual kitty windows via a window title bar (:pull:`9450`) -- A command palette to browse and trigger all mapped and unmapped actions +- A command palette (:sc:`command_palette`) to browse and trigger all mapped and unmapped actions (:pull:`9545`) - choose-files kitten: Fix JXL image preview not working (:iss:`9323`) @@ -269,6 +326,12 @@ Detailed list of changes - Allow double clicking on a tab to rename it (:pull:`9609`) +- :ac:`remote_control_script` resolve relative paths with respect to kitty + config directory (:iss:`9625`) + +- Splits layout: Add new mappable actions to maximize a window in the splits + layout (:iss:`9629`) + 0.45.0 [2025-12-24] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/integrations.rst b/docs/integrations.rst index d417dfa86..34b87e023 100644 --- a/docs/integrations.rst +++ b/docs/integrations.rst @@ -14,9 +14,9 @@ Image and document viewers Powered by kitty's :doc:`graphics-protocol` there exist many tools for viewing images and other types of documents directly in your terminal, even over SSH. -.. _tool_bookorat: +.. _tool_bookokrat: -`bookorat ` +`bookokrat `_ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ A terminal PDF/EPUB viewer diff --git a/docs/kittens/choose-files.rst b/docs/kittens/choose-files.rst index 1bde11733..1a0f3ed34 100644 --- a/docs/kittens/choose-files.rst +++ b/docs/kittens/choose-files.rst @@ -46,10 +46,15 @@ chosen in a command line, use, for example:: some-command $(kitten choose-file) -Note, that the above may not work in a complicated pipeline as it performs +Note that the above may not work in a complicated pipeline as it performs terminal I/O and needs exclusive access to the tty device while choosing a file. +.. note:: For content previews, this kitten uses some external programs. In + particular `ffmpeg `__ is needed for video + previews and `calibre `__ is needed + for ebook metadata and cover preiews. + Creating shortcuts to favorite/frequently used directories ------------------------------------------------------------ diff --git a/docs/launch.rst b/docs/launch.rst index fd03e13d5..6a984f118 100644 --- a/docs/launch.rst +++ b/docs/launch.rst @@ -185,6 +185,14 @@ create :file:`~/.config/kitty/mywatcher.py` and use :option:`launch --watcher` = # managing all tabs in a single OS Window. ... + def on_quit(boss: Boss, window: Window, data: dict[str, Any]) -> None: + # called when kitty is about to quit. This is called in *global watchers* + # only. It is called twice: once before the quit confirmation dialog is + # shown (data['confirmed'] will be False) and once after the user has + # confirmed quitting (data['confirmed'] will be True). Setting + # data['aborted'] to True will abort the quit in both cases. + ... + Every callback is passed a reference to the global ``Boss`` object as well as the ``Window`` object the action is occurring on. The ``data`` object is a dict diff --git a/docs/layouts.rst b/docs/layouts.rst index aa41d4da6..b33feae0d 100644 --- a/docs/layouts.rst +++ b/docs/layouts.rst @@ -184,10 +184,24 @@ define a few extra key bindings in :file:`kitty.conf`:: # window's size. map ctrl+. layout_action bias 80 + # Maximize the active window along the horizontal axis (fill full width), + # keeping other windows visible in their vertical positions. Press again to + # restore the original layout. + map ctrl+shift+right layout_action maximize horizontal + + # Maximize the active window along the vertical axis (fill full height), + # keeping other windows visible in their horizontal positions. Press again + # to restore the original layout. + map ctrl+shift+up layout_action maximize vertical + Windows can be resized using :ref:`window_resizing`. You can swap the windows in a split using the ``rotate`` action with an argument of ``180`` and rotate -and swap with an argument of ``270``. +and swap with an argument of ``270``. The ``maximize`` action expands the active +window to fill the maximum available space along a single axis while keeping +the rest of the layout intact. Use ``maximize horizontal`` to fill the full +width and ``maximize vertical`` to fill the full height. Calling it again +restores the original split sizes. This layout takes one option, ``split_axis`` that controls whether new windows are placed into vertical or horizontal splits when a :option:`--location diff --git a/docs/overview.rst b/docs/overview.rst index ccbd85ff2..2eaaaa56d 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -33,6 +33,9 @@ frames-per-second. Press :sc:`edit_config_file` in kitty to open its fully commented sample config file in your text editor. For details see the :doc:`configuration docs `. +You can quickly browse all available mappable actions by pressing +:sc:`command_palette`. + .. toctree:: :hidden: diff --git a/docs/remote-control.rst b/docs/remote-control.rst index 8da691a98..cc3c68f9f 100644 --- a/docs/remote-control.rst +++ b/docs/remote-control.rst @@ -298,7 +298,8 @@ control commands as you like and process their output. :ac:`launch` command with ``--type=background --allow-remote-control``. For more advanced usage, including fine grained permissions, setting env vars, command line interpolation, passing data to STDIN, etc. -the :doc:`launch ` command should be used. +the :doc:`launch ` command should be used. Relative paths to scripts +are interpreted with respect to the kitty config directory. .. note:: You do not need :opt:`allow_remote_control` to use these mappings, as they are not actual remote programs, but are simply a way to reuse the diff --git a/glfw/cocoa_init.m b/glfw/cocoa_init.m index e258c3d65..4ea90333f 100644 --- a/glfw/cocoa_init.m +++ b/glfw/cocoa_init.m @@ -311,6 +311,24 @@ static NSDictionary *global_shortcuts = nil; } } +- (void)applicationDidBecomeActive:(NSNotification *)notification { + (void)notification; + // When the application becomes active after switching spaces (e.g., swiping + // back from a fullscreen app on another space), macOS may not send + // windowDidBecomeKey: for the already-key window. This leaves GLFW thinking + // no window has focus (since windowDidResignKey: was sent when leaving). + // Ensure GLFW's focus state is updated to match the actual key window. + NSWindow *keyWindow = [NSApp keyWindow]; + if (keyWindow && !_glfw.focusedWindowId) { + for (_GLFWwindow *window = _glfw.windowListHead; window; window = window->next) { + if (window->ns.object == keyWindow) { + if (_glfw.focusedWindowId != window->id) _glfwInputWindowFocus(window, true); + break; + } + } + } +} + - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender { (void)sender; @@ -839,6 +857,24 @@ is_apple_jis_layout_function_key(NSEvent *event) { return [event keyCode] == 0x66 /* kVK_JIS_Eisu */ || [event keyCode] == 0x68 /* kVK_JIS_Kana */; } +static bool +has_apple_fn_global_shortcut(void) { + NSDictionary *hitoolbox_settings = [[NSUserDefaults standardUserDefaults] persistentDomainForName:@"com.apple.HIToolbox"]; + id obj = [hitoolbox_settings objectForKey:@"AppleFnUsageType"]; + if (![obj isKindOfClass:[NSNumber class]]) return false; + // Non-zero AppleFnUsageType means macOS has reserved Fn/Globe for a + // system action such as input source switching, emoji picker, or dictation. + return [obj integerValue] != 0; +} + +static bool +is_apple_fn_global_shortcut(NSEvent *event) { + if ([event keyCode] != 0x3f /* kVK_Function */) return false; + NSEventModifierFlags mods = USEFUL_MODS([event modifierFlags]); + if (mods != 0 && mods != NSEventModifierFlagFunction) return false; + return has_apple_fn_global_shortcut(); +} + GLFWAPI GLFWapplicationshouldhandlereopenfun glfwSetApplicationShouldHandleReopen(GLFWapplicationshouldhandlereopenfun callback) { GLFWapplicationshouldhandlereopenfun previous = handle_reopen_callback; handle_reopen_callback = callback; @@ -949,6 +985,10 @@ int _glfwPlatformInit(bool *supports_window_occlusion) debug_key("-------------- flags changed -----------------\n"); debug_key("%s\n", [[event description] UTF8String]); last_keydown_shortcut_event.virtual_key_code = 0xffff; + if (!_glfw.ignoreOSKeyboardProcessing && !_glfw.keyboard_grabbed && is_apple_fn_global_shortcut(event)) { + debug_key("flagsChanged triggered global fn shortcut ignoring\n"); + return event; + } // switching to the next input source is only confirmed when all modifier keys are released if (last_keydown_shortcut_event.input_source_switch_modifiers) { if (!([event modifierFlags] & last_keydown_shortcut_event.input_source_switch_modifiers)) diff --git a/glfw/cocoa_platform.h b/glfw/cocoa_platform.h index 4303d80e7..8974b8cf6 100644 --- a/glfw/cocoa_platform.h +++ b/glfw/cocoa_platform.h @@ -117,8 +117,11 @@ typedef UInt8 (*PFN_LMGetKbdType)(void); #define LMGetKbdType _glfw.ns.tis.GetKbdType typedef struct _GLFWDropData { - const char **mimes; + const char **mimes; // Original MIME list; strings are owned here, never reordered size_t mimes_count; + const char **copy_mimes; // Working copy passed to callbacks; pointers into mimes[] + size_t copy_mimes_count; // Accepted count after last callback + bool drag_accepted; id pasteboard; id data_mapping; id file_promise_mapping; @@ -138,6 +141,9 @@ typedef struct _GLFWwindowNS bool in_traditional_fullscreen; bool in_fullscreen_transition; bool suppress_frame_constraints; + id notch_cover_window; + unsigned int notch_cover_color; + float notch_cover_opacity; bool titlebar_hidden; unsigned long pre_full_screen_style_mask; CGRect pre_traditional_fullscreen_frame; diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index 078570e3e..e2d328181 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -99,6 +99,22 @@ polymorphic_string_as_utf8(id string) { return [characters UTF8String]; } +static bool +forward_dictation_selector_to_app(SEL selector, id sender) { + static SEL start_dictation_selector = NULL, stop_dictation_selector = NULL; + if (start_dictation_selector == NULL) { + start_dictation_selector = NSSelectorFromString(@"startDictation:"); + stop_dictation_selector = NSSelectorFromString(@"stopDictation:"); + } + if (selector != start_dictation_selector && selector != stop_dictation_selector) return false; + if ([NSApp respondsToSelector:selector]) { + debug_key("Forwarding %s to NSApp\n", [NSStringFromSelector(selector) UTF8String]); + [NSApp performSelector:selector withObject:sender]; + return true; + } + return false; +} + static uint32_t vk_code_to_functional_key_code(uint8_t key_code) { // {{{ switch(key_code) { @@ -579,6 +595,7 @@ static const NSRange kEmptyRange = { NSNotFound, 0 }; @end static void update_titlebar_button_visibility_after_fullscreen_transition(_GLFWwindow*, bool, bool); +static void _glfwUpdateNotchCover(_GLFWwindow*); @implementation GLFWWindowDelegate @@ -798,6 +815,7 @@ static void update_titlebar_button_visibility_after_fullscreen_transition(_GLFWw NSWindowStyleMask savedMask = window->ns.pre_full_screen_style_mask; CGRect savedFrame = window->ns.pre_traditional_fullscreen_frame; window->ns.in_traditional_fullscreen = false; + _glfwUpdateNotchCover(window); window->ns.suppress_frame_constraints = true; dispatch_async(dispatch_get_main_queue(), ^{ _GLFWwindow *w = NULL; @@ -842,6 +860,7 @@ static void update_titlebar_button_visibility_after_fullscreen_transition(_GLFWw // With the default macOS keybindings, pressing certain key combinations // (e.g. Ctrl+/, Ctrl+Cmd+Down/Left/Right) will produce a beep sound. debug_key("\n\tTextInputCtx: doCommandBySelector: (%s)\n", [NSStringFromSelector(selector) UTF8String]); + if (forward_dictation_selector_to_app(selector, nil)) return; } @end // }}} @@ -1459,8 +1478,10 @@ is_modifier_pressed(NSUInteger flags, NSUInteger target_mask, NSUInteger other_m static void free_drop_data(_GLFWwindow *window) { if (window->ns.drop_data.mimes) { - for (size_t i = 0; i < window->ns.drop_data.mimes_count; i++) free(window->ns.drop_data.mimes + i); + for (size_t i = 0; i < window->ns.drop_data.mimes_count; i++) free((void*)window->ns.drop_data.mimes[i]); + free(window->ns.drop_data.mimes); } + free(window->ns.drop_data.copy_mimes); // pointer array only; strings owned by mimes[] if (window->ns.drop_data.pasteboard) [window->ns.drop_data.pasteboard release]; if (window->ns.drop_data.data_mapping) [window->ns.drop_data.data_mapping release]; if (window->ns.drop_data.file_promise_mapping) { @@ -1477,12 +1498,24 @@ free_drop_data(_GLFWwindow *window) { } static void -update_drop_state(_GLFWwindow *window, size_t mime_count) { +update_drop_state(_GLFWwindow *window, size_t accepted_count) { _GLFWDropData *d = &window->ns.drop_data; - for (size_t i = mime_count; i < d->mimes_count; i++) { - if (d->mimes[i]) { free((void*)d->mimes[i]); d->mimes[i] = NULL; } + d->copy_mimes_count = accepted_count; + d->drag_accepted = accepted_count > 0; +} + +// Reset the working copy of mimes so the next callback sees the full original +// list. Returns false on allocation failure. +static bool +reset_drop_copy_mimes(_GLFWDropData *d) { + if (d->mimes_count == 0) { d->copy_mimes_count = 0; return true; } + if (!d->copy_mimes) { + d->copy_mimes = malloc(d->mimes_count * sizeof(const char*)); + if (!d->copy_mimes) return false; } - d->mimes_count = mime_count; + memcpy(d->copy_mimes, d->mimes, d->mimes_count * sizeof(const char*)); + d->copy_mimes_count = d->mimes_count; + return true; } - (NSDragOperation)draggingEntered:(id )sender @@ -1547,14 +1580,17 @@ update_drop_state(_GLFWwindow *window, size_t mime_count) { window->ns.drop_data.mimes = mime_array; window->ns.drop_data.mimes_count = mime_count; bool from_self = ([sender draggingSource] != nil); - mime_count = _glfwInputDropEvent(window, GLFW_DROP_ENTER, xpos, ypos, mime_array, mime_count, from_self); - update_drop_state(window, mime_count); - return mime_count ? NSDragOperationGeneric :NSDragOperationNone; + _GLFWDropData *d = &window->ns.drop_data; + if (reset_drop_copy_mimes(d)) { + size_t accepted_count = _glfwInputDropEvent(window, GLFW_DROP_ENTER, xpos, ypos, d->copy_mimes, d->copy_mimes_count, from_self); + update_drop_state(window, accepted_count); + } + return window->ns.drop_data.drag_accepted ? NSDragOperationGeneric : NSDragOperationNone; } - (NSDragOperation)draggingUpdated:(id )sender { - if (!window->ns.drop_data.mimes_count) return NSDragOperationNone; + if (!window->ns.drop_data.drag_accepted) return NSDragOperationNone; const NSRect contentRect = [window->ns.view frame]; const NSPoint pos = [sender draggingLocation]; double xpos = pos.x; @@ -1562,35 +1598,40 @@ update_drop_state(_GLFWwindow *window, size_t mime_count) { bool from_self = ([sender draggingSource] != nil); _GLFWDropData *d = &window->ns.drop_data; - size_t mime_count = _glfwInputDropEvent(window, GLFW_DROP_MOVE, xpos, ypos, d->mimes, d->mimes_count, from_self); - update_drop_state(window, mime_count); - return mime_count ? NSDragOperationGeneric :NSDragOperationNone; + if (reset_drop_copy_mimes(d)) { + size_t accepted_count = _glfwInputDropEvent(window, GLFW_DROP_MOVE, xpos, ypos, d->copy_mimes, d->copy_mimes_count, from_self); + update_drop_state(window, accepted_count); + } + return window->ns.drop_data.drag_accepted ? NSDragOperationGeneric : NSDragOperationNone; } - (void)draggingExited:(id )sender { bool from_self = ([sender draggingSource] != nil); _GLFWDropData *d = &window->ns.drop_data; - size_t mime_count = _glfwInputDropEvent(window, GLFW_DROP_LEAVE, 0, 0, d->mimes, d->mimes_count, from_self); - update_drop_state(window, mime_count); + if (reset_drop_copy_mimes(d)) { + size_t accepted_count = _glfwInputDropEvent(window, GLFW_DROP_LEAVE, 0, 0, d->copy_mimes, d->copy_mimes_count, from_self); + update_drop_state(window, accepted_count); + } free_drop_data(window); } - (BOOL)performDragOperation:(id )sender { - if (!window->ns.drop_data.mimes_count) return NO; + if (!window->ns.drop_data.drag_accepted) return NO; const NSRect contentRect = [window->ns.view frame]; const NSPoint pos = [sender draggingLocation]; double xpos = pos.x; double ypos = contentRect.size.height - pos.y; bool from_self = ([sender draggingSource] != nil); _GLFWDropData *d = &window->ns.drop_data; - size_t mime_count = _glfwInputDropEvent(window, GLFW_DROP_DROP, xpos, ypos, d->mimes, d->mimes_count, from_self); - if (d->mimes) { - update_drop_state(window, mime_count); + if (!reset_drop_copy_mimes(d)) return NO; + size_t num_accepted = _glfwInputDropEvent(window, GLFW_DROP_DROP, xpos, ypos, d->copy_mimes, d->copy_mimes_count, from_self); + if (d->copy_mimes) { + update_drop_state(window, num_accepted); window->ns.drop_data.pasteboard = [[sender draggingPasteboard] retain]; - for (size_t i = 0; i < d->mimes_count; i++) - _glfwPlatformRequestDropData(window, d->mimes[i]); + for (size_t i = 0; i < num_accepted; i++) + _glfwPlatformRequestDropData(window, d->copy_mimes[i]); } // Restore first-responder status after native DnD; the drag operation can // displace the content view from first responder, silently breaking keyboard @@ -1913,6 +1954,17 @@ void _glfwPlatformUpdateIMEState(_GLFWwindow *w, const GLFWIMEUpdateEvent *ev) { - (void)doCommandBySelector:(SEL)selector { debug_key("\n\tdoCommandBySelector: (%s)\n", [NSStringFromSelector(selector) UTF8String]); + if (forward_dictation_selector_to_app(selector, self)) return; +} + +- (void)startDictation:(id)sender +{ + forward_dictation_selector_to_app(_cmd, sender); +} + +- (void)stopDictation:(id)sender +{ + forward_dictation_selector_to_app(_cmd, sender); } - (BOOL)isAccessibilityElement @@ -2387,6 +2439,12 @@ void _glfwPlatformDestroyWindow(_GLFWwindow* window) if (_glfw.ns.disabledCursorWindow == window) _glfw.ns.disabledCursorWindow = NULL; free_drop_data(window); + if (window->ns.notch_cover_window) { + [w removeChildWindow:window->ns.notch_cover_window]; + [window->ns.notch_cover_window close]; + [window->ns.notch_cover_window release]; + window->ns.notch_cover_window = nil; + } [w orderOut:nil]; @@ -3313,6 +3371,44 @@ make_window_fullscreen_after_show(unsigned long long timer_id, void* data) { } } +static void +_glfwUpdateNotchCover(_GLFWwindow* w) { + NSWindow *window = w->ns.object; + if (w->ns.notch_cover_window) { + [window removeChildWindow:w->ns.notch_cover_window]; + [w->ns.notch_cover_window close]; + [w->ns.notch_cover_window release]; + w->ns.notch_cover_window = nil; + } + if (!w->ns.in_traditional_fullscreen) return; + if (@available(macOS 12.0, *)) { + CGFloat insetTop = window.screen.safeAreaInsets.top; + if (insetTop <= 0) return; + NSRect sf = [window.screen frame]; + NSWindow *bg_window = [[NSWindow alloc] initWithContentRect:sf styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + [bg_window setBackgroundColor:[NSColor clearColor]]; + [bg_window setHasShadow:NO]; + [bg_window setOpaque:NO]; + [bg_window setIgnoresMouseEvents:YES]; + [bg_window setReleasedWhenClosed:NO]; + [bg_window setColorSpace:[window colorSpace]]; + // Add a colored subview only in the notch strip area + NSView *notchView = [[NSView alloc] initWithFrame:NSMakeRect(0, sf.size.height - insetTop, sf.size.width, insetTop)]; + notchView.wantsLayer = YES; + unsigned int c = w->ns.notch_cover_color; + float a = w->ns.notch_cover_opacity; + notchView.layer.backgroundColor = [NSColor colorWithSRGBRed:((c >> 16) & 0xFF) / 255.0 + green:((c >> 8) & 0xFF) / 255.0 + blue:(c & 0xFF) / 255.0 + alpha:a].CGColor; + [bg_window.contentView addSubview:notchView]; + // must be above otherwise shadow of main window is rendered over bg_window + [window addChildWindow:bg_window ordered:NSWindowAbove]; + w->ns.notch_cover_window = bg_window; + [notchView release]; + } +} + bool _glfwPlatformToggleFullscreen(_GLFWwindow* w, unsigned int flags) { NSWindow *window = w->ns.object; bool made_fullscreen = true; @@ -3335,8 +3431,13 @@ bool _glfwPlatformToggleFullscreen(_GLFWwindow* w, unsigned int flags) { w->ns.pre_traditional_fullscreen_frame = [window frame]; [window setStyleMask: NSWindowStyleMaskBorderless]; [[NSApplication sharedApplication] setPresentationOptions: NSApplicationPresentationAutoHideMenuBar | NSApplicationPresentationAutoHideDock]; - [window setFrame:[window.screen frame] display:YES]; + NSRect screenFrame = [window.screen frame]; + if (@available(macOS 12.0, *)) { + screenFrame.size.height -= window.screen.safeAreaInsets.top; + } + [window setFrame:screenFrame display:YES]; w->ns.in_traditional_fullscreen = true; + _glfwUpdateNotchCover(w); } else { made_fullscreen = false; if (sm & NSWindowStyleMaskFullScreen) { @@ -3354,6 +3455,7 @@ bool _glfwPlatformToggleFullscreen(_GLFWwindow* w, unsigned int flags) { [window setStyleMask: w->ns.pre_full_screen_style_mask]; [[NSApplication sharedApplication] setPresentationOptions: NSApplicationPresentationDefault]; w->ns.in_traditional_fullscreen = false; + _glfwUpdateNotchCover(w); } } } else { @@ -3925,6 +4027,9 @@ GLFWAPI void glfwCocoaSetWindowChrome(GLFWwindow *w, unsigned int color, bool us // HACK: Changing the style mask can cause the first responder to be cleared [nsw makeFirstResponder:window->ns.view]; + window->ns.notch_cover_color = color; + window->ns.notch_cover_opacity = background_opacity; + if (window->ns.notch_cover_window) _glfwUpdateNotchCover(window); }} GLFWAPI uint32_t diff --git a/glfw/input.c b/glfw/input.c index ba84a54b6..d35f11ba6 100644 --- a/glfw/input.c +++ b/glfw/input.c @@ -402,7 +402,13 @@ void _glfwInputCursorEnter(_GLFWwindow* window, bool entered) window->callbacks.cursorEnter((GLFWwindow*) window, entered); } -// Notifies shared code of a drop event +// Notifies shared code of a drop event. +// The caller is responsible for passing a mutable working-copy of the mimes +// array (reset to the full original list before each call) so that the +// callback can sort/filter in-place without touching the backend's canonical +// storage. The return value is ev.num_mimes after the callback returns, +// i.e. the number of accepted (possibly reordered) mimes starting at +// mimes[0]. size_t _glfwInputDropEvent(_GLFWwindow *window, GLFWDropEventType type, double xpos, double ypos, const char** mimes, size_t num_mimes, bool from_self) { if (!window->callbacks.drop_event) return 0; GLFWDropEvent ev = { diff --git a/glfw/wl_init.c b/glfw/wl_init.c index e38f651ae..be840b40f 100644 --- a/glfw/wl_init.c +++ b/glfw/wl_init.c @@ -206,27 +206,35 @@ pointer_handle_frame(void *data UNUSED, struct wl_pointer *pointer UNUSED) { _GLFWwindow* window = _glfw.wl.pointerFocus; if (!window) return; GLFWScrollEvent ev = {.keyboard_modifiers=_glfw.wl.xkb.states.modifiers}; + bool found = false; if (info.discrete.y_axis_type != AXIS_EVENT_UNKNOWN) { ev.unscaled.y = info.discrete.y; if (info.discrete.y_axis_type == AXIS_EVENT_VALUE120) ev.offset_type = GLFW_SCROLL_OFFEST_V120; + found = true; } else if (info.continuous.y_axis_type != AXIS_EVENT_UNKNOWN) { ev.offset_type = GLFW_SCROLL_OFFEST_HIGHRES; ev.unscaled.y = info.continuous.y; + found = true; } if (info.discrete.x_axis_type != AXIS_EVENT_UNKNOWN) { ev.unscaled.x = info.discrete.x; if (info.discrete.x_axis_type == AXIS_EVENT_VALUE120) ev.offset_type = GLFW_SCROLL_OFFEST_V120; + found = true; } else if (info.continuous.x_axis_type != AXIS_EVENT_UNKNOWN) { ev.offset_type = GLFW_SCROLL_OFFEST_HIGHRES; ev.unscaled.x = info.continuous.x; + found = true; } + bool stopped = info.y_stop_received || info.x_stop_received; + if (!found && stopped) ev.offset_type = window->wl.prev_frame_offset_type; ev.unscaled.x *= -1; const double scale = ev.offset_type == GLFW_SCROLL_OFFEST_HIGHRES ? _glfwWaylandWindowScale(window) : 1; ev.x_offset = scale * ev.unscaled.x; ev.y_offset = scale * ev.unscaled.y; glfw_handle_scroll_event_for_momentum( - window, &ev, info.y_stop_received || info.x_stop_received, info.source_type == WL_POINTER_AXIS_SOURCE_FINGER); + window, &ev, stopped, info.source_type == WL_POINTER_AXIS_SOURCE_FINGER); + window->wl.prev_frame_offset_type = ev.offset_type; /* clear pointer_curr_axis_info for next frame */ memset(&info, 0, sizeof(info)); } diff --git a/glfw/wl_platform.h b/glfw/wl_platform.h index 4781c802b..8e8f72ba7 100644 --- a/glfw/wl_platform.h +++ b/glfw/wl_platform.h @@ -212,6 +212,7 @@ typedef struct _GLFWwindowWayland uint32_t source_type; monotonic_t x_start_time, x_stop_time, y_stop_time, y_start_time; } pointer_curr_axis_info; + GLFWOffsetType prev_frame_offset_type; _GLFWcursor* currentCursor; double cursorPosX, cursorPosY, allCursorPosX, allCursorPosY; @@ -310,6 +311,8 @@ typedef struct _GLFWWaylandDataOffer struct wl_surface *surface; const char **mimes; size_t mimes_capacity, mimes_count; + const char **copy_mimes; // Working copy passed to callbacks; pointers into mimes[] + size_t copy_mimes_count; // Count of entries in copy_mimes (accepted count after callback) bool drag_accepted, dropped; uint32_t serial; struct { diff --git a/glfw/wl_window.c b/glfw/wl_window.c index 3a3feeac9..ff3b8b69c 100644 --- a/glfw/wl_window.c +++ b/glfw/wl_window.c @@ -1588,6 +1588,14 @@ void _glfwPlatformSetWindowTitle(_GLFWwindow* window, const char* title) void _glfwPlatformSetWindowIcon(_GLFWwindow* window, int count, const GLFWimage* images) { + if (is_layer_shell(window)) { + _glfwInputError(GLFW_PLATFORM_ERROR, "Wayland: Cannot set window icon on layer shell surfaces"); + return; + } + if (!window->wl.xdg.toplevel) { + _glfwInputError(GLFW_PLATFORM_ERROR, "Wayland: Ignoring attempt to set window icon on window without a toplevel"); + return; + } if (!_glfw.wl.xdg_toplevel_icon_manager_v1) { static bool warned_once = false; if (!warned_once) { @@ -2364,10 +2372,25 @@ destroy_data_offer(_GLFWWaylandDataOffer *offer) { for (size_t i = 0; i < offer->mimes_count; i++) free((char*)offer->mimes[i]); free(offer->mimes); } + free(offer->copy_mimes); // pointer array only; strings are owned by mimes[] if (offer->requested_drop_data) destroy_drop_data(offer); memset(offer, 0, sizeof(offer[0])); } +// Reset the working copy of mimes so the next callback sees the full original +// list. Returns false on allocation failure. +static bool +reset_copy_mimes(_GLFWWaylandDataOffer *offer) { + if (offer->mimes_count == 0) { offer->copy_mimes_count = 0; return true; } + if (!offer->copy_mimes) { + offer->copy_mimes = malloc(offer->mimes_count * sizeof(const char*)); + if (!offer->copy_mimes) return false; + } + memcpy(offer->copy_mimes, offer->mimes, offer->mimes_count * sizeof(const char*)); + offer->copy_mimes_count = offer->mimes_count; + return true; +} + static void mark_data_offer(_GLFWWaylandDataOffer *ans, void *id) { if (ans->id) destroy_data_offer(ans); @@ -2480,15 +2503,12 @@ static void handle_primary_selection_offer(void *data UNUSED, struct zwp_primary // Helper function to update drop state from callback results static void -update_drop_state(_GLFWWaylandDataOffer *d, _GLFWwindow* window UNUSED, size_t mime_count) { - for (size_t i = mime_count; i < d->mimes_count; i++) { - if (d->mimes[i]) { free((void*)d->mimes[i]); d->mimes[i] = NULL; } - } - d->mimes_count = mime_count; - bool accepted = mime_count > 0; +update_drop_state(_GLFWWaylandDataOffer *d, _GLFWwindow* window UNUSED, size_t accepted_count) { + d->copy_mimes_count = accepted_count; + bool accepted = accepted_count > 0; bool acceptance_changed = (accepted != d->drag_accepted); - // The first MIME in the sorted list is the preferred one for drop - const char* new_preferred_mime = (accepted && mime_count > 0) ? d->mimes[0] : NULL; + // The first entry in the accepted (sorted) copy is the preferred MIME. + const char* new_preferred_mime = (accepted && d->copy_mimes) ? d->copy_mimes[0] : NULL; bool mime_changed = false; // Check if the preferred MIME changed @@ -2522,10 +2542,12 @@ drag_enter(void *data UNUSED, struct wl_data_device *wl_data_device UNUSED, uint if (window->wl.surface == surface) { double xpos = wl_fixed_to_double(x); double ypos = wl_fixed_to_double(y); - size_t mime_count = _glfwInputDropEvent( - window, GLFW_DROP_ENTER, xpos, ypos, - offer->mimes, offer->mimes_count, offer->is_self_offer); - update_drop_state(offer, window, mime_count); + if (reset_copy_mimes(offer)) { + size_t mime_count = _glfwInputDropEvent( + window, GLFW_DROP_ENTER, xpos, ypos, + offer->copy_mimes, offer->copy_mimes_count, offer->is_self_offer); + update_drop_state(offer, window, mime_count); + } break; } window = window->next; @@ -2563,7 +2585,7 @@ _glfwPlatformReadAvailableDropData(GLFWwindow *w, GLFWDropEvent *ev, char *buffe for (size_t o = 0; o < offer->dd_count; o++) { if (offer->requested_drop_data[o].fd == fd) { ssize_t ret; - do { ret = read(fd, buffer, sz); } while (ret < 0 && errno == EINTR); + do { ret = read(fd, buffer, sz); } while (ret < 0 && (errno == EINTR || errno == EAGAIN)); if (ret <= 0) removeWatch(&_glfw.wl.eventLoopData, offer->requested_drop_data[o].watch_id); return ret < 0 ? -errno : ret; } @@ -2643,9 +2665,11 @@ drop(void *data UNUSED, struct wl_data_device *wl_data_device UNUSED) { _GLFWwindow* window = _glfw.windowListHead; while (window) { if (window->wl.surface == offer->surface) { - size_t num_mimes = _glfwInputDropEvent(window, GLFW_DROP_DROP, 0, 0, offer->mimes, offer->mimes_count, offer->is_self_offer); - if (!offer->mimes) { destroy_data_offer(offer); return; } - for (size_t i = 0; i < num_mimes; i++) request_drop_data(offer, offer->mimes[i]); + if (reset_copy_mimes(offer)) { + size_t num_accepted = _glfwInputDropEvent(window, GLFW_DROP_DROP, 0, 0, offer->copy_mimes, offer->copy_mimes_count, offer->is_self_offer); + if (!offer->copy_mimes) { destroy_data_offer(offer); return; } + for (size_t i = 0; i < num_accepted; i++) request_drop_data(offer, offer->copy_mimes[i]); + } break; } window = window->next; @@ -2661,9 +2685,11 @@ motion(void *data UNUSED, struct wl_data_device *wl_data_device UNUSED, uint32_t if (window->wl.surface == offer->surface) { double xpos = wl_fixed_to_double(x); double ypos = wl_fixed_to_double(y); - size_t mime_count = _glfwInputDropEvent( - window, GLFW_DROP_MOVE, xpos, ypos, offer->mimes, offer->mimes_count, offer->is_self_offer); - update_drop_state(offer, window, mime_count); + if (reset_copy_mimes(offer)) { + size_t mime_count = _glfwInputDropEvent( + window, GLFW_DROP_MOVE, xpos, ypos, offer->copy_mimes, offer->copy_mimes_count, offer->is_self_offer); + update_drop_state(offer, window, mime_count); + } break; } window = window->next; @@ -2673,8 +2699,8 @@ motion(void *data UNUSED, struct wl_data_device *wl_data_device UNUSED, uint32_t void _glfwPlatformRequestDropUpdate(_GLFWwindow* window) { _GLFWWaylandDataOffer *d = &_glfw.wl.drop_data_offer; - if (d->id) { - size_t mime_count = _glfwInputDropEvent(window, GLFW_DROP_STATUS_UPDATE, 0, 0, d->mimes, d->mimes_count, d->is_self_offer); + if (d->id && reset_copy_mimes(d)) { + size_t mime_count = _glfwInputDropEvent(window, GLFW_DROP_STATUS_UPDATE, 0, 0, d->copy_mimes, d->copy_mimes_count, d->is_self_offer); update_drop_state(d, window, mime_count); } } @@ -3151,6 +3177,7 @@ finish_drag_write(size_t i) { dr.watch_id = 0; if (dr.fd > -1) safe_close(dr.fd); dr.fd = -1; + free(dr.pending_data); dr.pending_data = NULL; dr.sz = 0; dr.offset = 0; free((void*)dr.mime_type); dr.mime_type = NULL; } @@ -3172,31 +3199,49 @@ write_as_much_as_possible(int fd, const char *data, size_t sz) { static void send_drag_data(_GLFWwindow *window, size_t i) { ssize_t ret; - bool has_preset_data = _glfw.drag.items[i].data_size > 0; + // Find the item matching the mime type for this data request. + // We cannot use i directly to index _glfw.drag.items because the compositor + // may call drag_source_send multiple times (once per target entered), making + // data_requests grow independently of the items array. + size_t item_idx = _glfw.drag.item_count; // sentinel: not found + for (size_t j = 0; j < _glfw.drag.item_count; j++) { + if (dr.mime_type && _glfw.drag.items[j].mime_type && + strcmp(_glfw.drag.items[j].mime_type, dr.mime_type) == 0) { + item_idx = j; break; + } + } + if (item_idx == _glfw.drag.item_count) { + _glfwInputError(GLFW_PLATFORM_ERROR, + "Wayland: compositor requested data for unrecognised MIME type: %s", + dr.mime_type ? dr.mime_type : "(null)"); + } + bool has_preset_data = item_idx < _glfw.drag.item_count && _glfw.drag.items[item_idx].data_size > 0; + // On write error, only close this pipe; do NOT destroy the wl_data_source + // since the compositor must send cancelled/dnd_finished before that is safe. #define on_fail _glfwInputError(\ GLFW_PLATFORM_ERROR, "Wayland: failed to write drag source data to pipe with error: %s", strerror(errno)); \ - cancel_drag(GLFW_DRAG_CANCELLED); + finish_drag_write(i); return; if (dr.sz > dr.offset) { ret = write_as_much_as_possible(dr.fd, dr.pending_data + dr.offset, dr.sz - dr.offset); if (ret < 0) { on_fail; } else { dr.offset += ret; if (dr.offset >= dr.sz) { - free(dr.pending_data); dr.sz = 0; dr.offset = 0; - if (has_preset_data) finish_drag_write(i); + free(dr.pending_data); dr.pending_data = NULL; dr.sz = 0; dr.offset = 0; + finish_drag_write(i); } } } else if (has_preset_data) { - do { ret = write(dr.fd, _glfw.drag.items[i].optional_data, _glfw.drag.items[i].data_size); } while (ret < 0 && errno == EINTR); + do { ret = write(dr.fd, _glfw.drag.items[item_idx].optional_data, _glfw.drag.items[item_idx].data_size); } while (ret < 0 && errno == EINTR); if (ret < 0) { on_fail; } else { - if ((size_t)ret >= _glfw.drag.items[i].data_size) { + if ((size_t)ret >= _glfw.drag.items[item_idx].data_size) { finish_drag_write(i); } else { - void *pending = malloc(_glfw.drag.items[i].data_size - ret); - if (!pending) { on_fail; } else { - dr.pending_data = pending; dr.sz = _glfw.drag.items[i].data_size - ret; dr.offset = 0; + void *pending = malloc(_glfw.drag.items[item_idx].data_size - ret); + if (!pending) { errno = ENOMEM; on_fail; } else { + dr.pending_data = pending; dr.sz = _glfw.drag.items[item_idx].data_size - ret; dr.offset = 0; } } } @@ -3205,21 +3250,20 @@ send_drag_data(_GLFWwindow *window, size_t i) { _glfwInputDragSourceRequest(window, &ev); if (ev.err_num) { if (ev.err_num == EAGAIN) { removeWatch(&_glfw.wl.eventLoopData, dr.watch_id); dr.watch_id = 0; } - else cancel_drag(GLFW_DRAG_CANCELLED); + else { finish_drag_write(i); return; } } else { if (ev.data_sz) { ret = write_as_much_as_possible(dr.fd, ev.data, ev.data_sz); - if (ret >= 0) { - if ((size_t)ret < ev.data_sz) { - void *pending = malloc(ev.data_sz - ret); - if (!pending) { on_fail; } else { - dr.pending_data = pending; dr.sz = ev.data_sz - ret; dr.offset = 0; - memcpy(pending, ev.data + ret, dr.sz); - } + if (ret >= 0 && (size_t)ret < ev.data_sz) { + void *pending = malloc(ev.data_sz - ret); + if (!pending) { ret = -1; } else { + dr.pending_data = pending; dr.sz = ev.data_sz - ret; dr.offset = 0; + memcpy(pending, ev.data + ret, dr.sz); } } _glfwInputDragSourceRequest(window, &ev); if (ret < 0) { on_fail; } + else if ((size_t)ret >= ev.data_sz) { finish_drag_write(i); } } else finish_drag_write(i); } } diff --git a/glfw/x11_init.c b/glfw/x11_init.c index 0b518af51..5bea3c3fc 100644 --- a/glfw/x11_init.c +++ b/glfw/x11_init.c @@ -216,8 +216,12 @@ read_xi_scroll_devices(void) { if (_glfw.x11.xi.num_scroll_devices >= arraysz(_glfw.x11.xi.scroll_devices)) continue; d = &_glfw.x11.xi.scroll_devices[_glfw.x11.xi.num_scroll_devices++]; *d = (XIScrollDevice){ - .is_highres=is_highres, .is_finger_based=is_finger_based, .deviceid=device->deviceid, .sourceid=scroll->sourceid, + .is_finger_based=is_finger_based, .deviceid=device->deviceid, .sourceid=scroll->sourceid, }; + if (is_highres) { + d->type_detected = true; + d->offset_type = GLFW_SCROLL_OFFEST_HIGHRES; + } memcpy(d->name, device->name, MIN(sizeof(d->name)-1, strlen(device->name))); } if (d->num_valuators >= arraysz(d->valuators)) continue; diff --git a/glfw/x11_platform.h b/glfw/x11_platform.h index a74dae830..33f32dfe8 100644 --- a/glfw/x11_platform.h +++ b/glfw/x11_platform.h @@ -239,12 +239,14 @@ typedef struct XIScrollValuator { } XIScrollValuator; typedef struct XIScrollDevice { - bool is_highres; bool is_finger_based; + bool type_detected; int deviceid, sourceid; XIScrollValuator valuators[8]; unsigned num_valuators; char name[32]; + unsigned num_events; + GLFWOffsetType offset_type; } XIScrollDevice; typedef struct XdndSelectionRequest { @@ -393,8 +395,11 @@ typedef struct _GLFWlibraryX11 char format[256]; int format_priority; Window target_window; // For drag events: the window being dragged over - const char** mimes; // Cached MIME types from drag enter - size_t mimes_count; // Current count of MIME types (may be reduced by callback) + const char** mimes; // Cached MIME types from drag enter (original, never reordered) + size_t mimes_count; // Count of MIME types (full original list, never reduced) + const char** copy_mimes; // Working copy passed to callbacks; pointers into mimes[] + size_t copy_mimes_count; // Accepted count after last callback + bool drag_accepted; // Whether the callback accepted at least one MIME type bool from_self, dropped; Time drop_time; XdndSelectionRequest *selection_requests; diff --git a/glfw/x11_window.c b/glfw/x11_window.c index ec05354be..e57aefe66 100644 --- a/glfw/x11_window.c +++ b/glfw/x11_window.c @@ -29,6 +29,7 @@ #define _GNU_SOURCE #include "internal.h" +#include "math.h" #include "backend_utils.h" #include "linux_notify.h" #include "../kitty/monotonic.h" @@ -1414,6 +1415,11 @@ handle_mouse_move_event(_GLFWwindow *window, const int x, const int y) { window->x11.lastCursorPosY = y; } +static bool +number_has_fractional_part(double x) { + return fabs(x - round(x)) >= 1e-6; +} + static void handle_xi_motion_event(_GLFWwindow *window, XIDeviceEvent *de) { XIScrollDevice *d = NULL; @@ -1441,17 +1447,35 @@ handle_xi_motion_event(_GLFWwindow *window, XIDeviceEvent *de) { scroll_valuator_found = true; double delta = value - v->value; v->value = value; - if (v->is_vertical) delta *= -1; + delta *= -1; double *off = v->is_vertical ? &yOffset : &xOffset; *off = delta; - if (!d->is_highres) { - if (v->increment == 120.) type = GLFW_SCROLL_OFFEST_V120; - else { - type = GLFW_SCROLL_OFFSET_LINES; - if (v->increment != 0) *off /= v->increment; + d->num_events++; + if (!d->type_detected) { + if (v->increment == 120.) { + d->type_detected = true; + d->offset_type = GLFW_SCROLL_OFFEST_V120; + } else { + bool delta_is_fractional = number_has_fractional_part(delta); + if (delta_is_fractional) { + if (fabs(delta * 120 - round(delta * 120)) < 0.01) { + d->type_detected = d->num_events > 2; + d->offset_type = GLFW_SCROLL_OFFEST_V120; + } else { + d->type_detected = true; + d->offset_type = GLFW_SCROLL_OFFEST_HIGHRES; + } + } else { + d->type_detected = d->num_events > 2; + d->offset_type = GLFW_SCROLL_OFFSET_LINES; + } } } + if (d->offset_type == GLFW_SCROLL_OFFSET_LINES) { + if (v->increment != 0) *off /= v->increment; + } } + type = d->offset_type; if (xOffset != 0 || yOffset != 0) { // Get keyboard modifiers int mods = translateState(de->mods.effective); @@ -1465,7 +1489,7 @@ handle_xi_motion_event(_GLFWwindow *window, XIDeviceEvent *de) { }; // For high-resolution, finger-based scrolling, use timer-based momentum scrolling - if (d->is_highres && d->is_finger_based && type == GLFW_SCROLL_OFFEST_HIGHRES) { + if (d->is_finger_based && type == GLFW_SCROLL_OFFEST_HIGHRES) { // Reset the timer on each scroll event x11_cancel_momentum_scroll_timer(); @@ -1498,7 +1522,7 @@ handle_xi_motion_event(_GLFWwindow *window, XIDeviceEvent *de) { static void end_drop(_GLFWwindow *window, GLFWDragOperationType op) { - bool accepted = dnd.mimes_count > 0 || dnd.dropped; + bool accepted = dnd.drag_accepted || dnd.dropped; XEvent reply = { ClientMessage }; reply.xclient.window = dnd.source; reply.xclient.message_type = _glfw.x11.XdndFinished; @@ -1520,14 +1544,12 @@ end_drop(_GLFWwindow *window, GLFWDragOperationType op) { static void -update_drop_state(_GLFWwindow* window, size_t mime_count) { - for (size_t i = mime_count; i < dnd.mimes_count; i++) { - if (dnd.mimes[i]) { XFree((void*)dnd.mimes[i]); dnd.mimes[i] = NULL; } - } - dnd.mimes_count = mime_count; - bool accepted = mime_count > 0; - // The first MIME in the sorted list is the preferred one for drop - const char* new_preferred_mime = (accepted && mime_count > 0) ? dnd.mimes[0] : NULL; +update_drop_state(_GLFWwindow* window, size_t accepted_count) { + dnd.copy_mimes_count = accepted_count; + bool accepted = accepted_count > 0; + dnd.drag_accepted = accepted; + // The first entry in the accepted (sorted) copy is the preferred MIME. + const char* new_preferred_mime = (accepted && dnd.copy_mimes) ? dnd.copy_mimes[0] : NULL; // Check if the preferred MIME changed bool mime_changed = strncmp(new_preferred_mime ? new_preferred_mime : "", dnd.format, arraysz(dnd.format)) != 0; if (mime_changed) { @@ -1566,12 +1588,30 @@ free_dnd_mimes(void) { dnd.mimes = NULL; dnd.mimes_count = 0; } + free(dnd.copy_mimes); // pointer array only; strings are owned by mimes[] + dnd.copy_mimes = NULL; + dnd.copy_mimes_count = 0; +} + +// Reset the working copy of mimes so the next callback sees the full original +// list. Returns false on allocation failure. +static bool +reset_dnd_copy_mimes(void) { + if (dnd.mimes_count == 0) { dnd.copy_mimes_count = 0; return true; } + if (!dnd.copy_mimes) { + dnd.copy_mimes = malloc(dnd.mimes_count * sizeof(const char*)); + if (!dnd.copy_mimes) return false; + } + memcpy(dnd.copy_mimes, dnd.mimes, dnd.mimes_count * sizeof(const char*)); + dnd.copy_mimes_count = dnd.mimes_count; + return true; } void free_dnd_data(void) { dnd.source = None; dnd.target_window = None; + dnd.drag_accepted = false; free_dnd_mimes(); if (dnd.selection_requests) { for (size_t i = 0; i < dnd.selection_requests_count; i++) { @@ -1640,14 +1680,13 @@ drop_start(_GLFWwindow *window, XEvent *event) { update_dnd_mimes(event); dnd.from_self = _glfw.x11.drag.source_window != None && dnd.source == _glfw.x11.drag.source_window; // Position is not known yet at enter time, will be updated with XdndPosition - size_t mimes_count = _glfwInputDropEvent( - window, GLFW_DROP_ENTER, 0, 0, dnd.mimes, dnd.mimes_count, dnd.from_self); - update_drop_state(window, mimes_count); - // Update cached mime count with callback result - if (dnd.mimes_count > 0) { - // The first MIME type in the reordered list is the preferred one - strncpy(dnd.format, dnd.mimes[0], arraysz(dnd.format) - 1); - dnd.format_priority = 1; + if (reset_dnd_copy_mimes()) { + size_t accepted_count = _glfwInputDropEvent( + window, GLFW_DROP_ENTER, 0, 0, dnd.copy_mimes, dnd.copy_mimes_count, dnd.from_self); + update_drop_state(window, accepted_count); + // Set format_priority when the callback accepted at least one MIME type. + // dnd.format has already been updated by update_drop_state. + if (accepted_count > 0) dnd.format_priority = 1; } } @@ -1674,8 +1713,10 @@ drop_move(_GLFWwindow *window, XEvent *event) { _glfwReleaseErrorHandlerX11(); if (_glfw.x11.errorCode != Success) _glfwInputError(GLFW_PLATFORM_ERROR, "X11: Failed to get DND event position"); _glfwInputCursorPos(window, xpos, ypos); - size_t mimes_count = _glfwInputDropEvent(window, GLFW_DROP_MOVE, xpos, ypos, dnd.mimes, dnd.mimes_count, dnd.from_self); - update_drop_state(window, mimes_count); + if (reset_dnd_copy_mimes()) { + size_t mimes_count = _glfwInputDropEvent(window, GLFW_DROP_MOVE, xpos, ypos, dnd.copy_mimes, dnd.copy_mimes_count, dnd.from_self); + update_drop_state(window, mimes_count); + } } void @@ -1684,8 +1725,10 @@ _glfwPlatformRequestDropUpdate(_GLFWwindow* window) { if (dnd.source == None || dnd.target_window != window->x11.handle) return; // Call the drag callback with STATUS_UPDATE event to get updated state // Position values are not valid for this event type - size_t mimes_count = _glfwInputDropEvent(window, GLFW_DROP_STATUS_UPDATE, 0, 0, dnd.mimes, dnd.mimes_count, dnd.from_self); - update_drop_state(window, mimes_count); + if (reset_dnd_copy_mimes()) { + size_t mimes_count = _glfwInputDropEvent(window, GLFW_DROP_STATUS_UPDATE, 0, 0, dnd.copy_mimes, dnd.copy_mimes_count, dnd.from_self); + update_drop_state(window, mimes_count); + } } @@ -1695,9 +1738,10 @@ drop(_GLFWwindow *window, XEvent *event) { if (dnd.version > _GLFW_XDND_VERSION || dnd.version < 2) return; dnd.dropped = true; dnd.drop_time = (unsigned long)event->xclient.data.l[2]; - size_t mimes_count = _glfwInputDropEvent(window, GLFW_DROP_DROP, 0, 0, dnd.mimes, dnd.mimes_count, dnd.from_self); - if (!dnd.mimes) return; - for (size_t i = 0; i < mimes_count; i++) _glfwPlatformRequestDropData(window, dnd.mimes[i]); + if (!reset_dnd_copy_mimes()) return; + size_t num_accepted = _glfwInputDropEvent(window, GLFW_DROP_DROP, 0, 0, dnd.copy_mimes, dnd.copy_mimes_count, dnd.from_self); + if (!dnd.copy_mimes) return; + for (size_t i = 0; i < num_accepted; i++) _glfwPlatformRequestDropData(window, dnd.copy_mimes[i]); } static void @@ -4004,8 +4048,11 @@ send_xdnd_enter(Window target, int version) { } } + _glfwGrabErrorHandlerX11(); XSendEvent(_glfw.x11.display, target, False, NoEventMask, &event); XFlush(_glfw.x11.display); + _glfwReleaseErrorHandlerX11(); + if (_glfw.x11.errorCode == BadWindow) _glfw.x11.drag.current_target = None; } // Send XdndPosition message to target window @@ -4024,9 +4071,12 @@ send_xdnd_position(Window target, int root_x, int root_y, Time timestamp) { event.xclient.data.l[3] = timestamp; event.xclient.data.l[4] = _glfw.x11.drag.action_atom; + _glfwGrabErrorHandlerX11(); XSendEvent(_glfw.x11.display, target, False, NoEventMask, &event); XFlush(_glfw.x11.display); - _glfw.x11.drag.waiting_for_status = true; + _glfwReleaseErrorHandlerX11(); + if (_glfw.x11.errorCode == BadWindow) _glfw.x11.drag.current_target = None; + else _glfw.x11.drag.waiting_for_status = true; } // Send XdndLeave message to target window @@ -4041,8 +4091,11 @@ send_xdnd_leave(Window target) { event.xclient.format = 32; event.xclient.data.l[0] = _glfw.x11.drag.source_window; + _glfwGrabErrorHandlerX11(); XSendEvent(_glfw.x11.display, target, False, NoEventMask, &event); XFlush(_glfw.x11.display); + _glfwReleaseErrorHandlerX11(); + // BadWindow on leave is benign – the target window is already gone } // Send XdndDrop message to target window @@ -4059,8 +4112,19 @@ send_xdnd_drop(Window target, Time timestamp) { event.xclient.data.l[1] = 0; // Reserved event.xclient.data.l[2] = timestamp; + _glfwGrabErrorHandlerX11(); XSendEvent(_glfw.x11.display, target, False, NoEventMask, &event); XFlush(_glfw.x11.display); + _glfwReleaseErrorHandlerX11(); + if (_glfw.x11.errorCode == BadWindow) { + // Target window was destroyed; cancel the drag gracefully + _GLFWwindow *window = _glfwWindowForId(_glfw.drag.window_id); + if (window) { + GLFWDragEvent ev = {.type = GLFW_DRAG_CANCELLED}; + _glfwInputDragSourceRequest(window, &ev); + } + _glfwFreeDragSourceData(); + } } // Render thumbnail pixels into _glfw.x11.drag.thumbnail_pixmap / thumbnail_gc. diff --git a/go.mod b/go.mod index 5f3a50421..716e39fe9 100644 --- a/go.mod +++ b/go.mod @@ -19,12 +19,12 @@ require ( github.com/kovidgoyal/imaging v1.8.20 github.com/nwaples/rardecode/v2 v2.2.2 github.com/seancfoley/ipaddress-go v1.7.1 - github.com/shirou/gopsutil/v4 v4.26.1 + github.com/shirou/gopsutil/v4 v4.26.2 github.com/ulikunitz/xz v0.5.15 github.com/zeebo/xxh3 v1.1.0 golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b golang.org/x/image v0.36.0 - golang.org/x/sys v0.41.0 + golang.org/x/sys v0.42.0 golang.org/x/text v0.34.0 howett.net/plist v1.0.1 ) @@ -36,7 +36,7 @@ require ( // replace github.com/kovidgoyal/imaging => ../imaging require ( - github.com/ebitengine/purego v0.9.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a // indirect diff --git a/go.sum b/go.sum index a97bcc882..288f16f21 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -52,8 +52,8 @@ github.com/seancfoley/bintree v1.3.1 h1:cqmmQK7Jm4aw8gna0bP+huu5leVOgHGSJBEpUx3E github.com/seancfoley/bintree v1.3.1/go.mod h1:hIUabL8OFYyFVTQ6azeajbopogQc2l5C/hiXMcemWNU= github.com/seancfoley/ipaddress-go v1.7.1 h1:fDWryS+L8iaaH5RxIKbY0xB5Z+Zxk8xoXLN4S4eAPdQ= github.com/seancfoley/ipaddress-go v1.7.1/go.mod h1:TQRZgv+9jdvzHmKoPGBMxyiaVmoI0rYpfEk8Q/sL/Iw= -github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= -github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= @@ -74,8 +74,8 @@ golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/kittens/choose_files/results.go b/kittens/choose_files/results.go index 1b6ea56a6..840cfd14c 100644 --- a/kittens/choose_files/results.go +++ b/kittens/choose_files/results.go @@ -112,12 +112,10 @@ func (h *Handler) render_match_with_positions(text string, add_ellipsis bool, po for chunk := range split_up_text(text, add_ellipsis, positions) { if chunk.text != "" { if chunk.emphasize { - h.lp.QueueWriteString(prefix) - defer func() { - h.lp.QueueWriteString(suffix) - }() + h.lp.QueueWriteString(prefix + chunk.text + suffix) + } else { + h.lp.QueueWriteString(chunk.text) } - h.lp.QueueWriteString(chunk.text) } } } diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go index 9a93d9b7a..d9e983163 100644 --- a/kittens/command_palette/main.go +++ b/kittens/command_palette/main.go @@ -790,7 +790,7 @@ func (h *Handler) triggerSelected() { func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { if tty.IsTerminal(os.Stdin.Fd()) { - return 1, fmt.Errorf("This kitten must only be run via a mapping in kitty.conf") + return 1, fmt.Errorf("This kitten must only be run via the command_palette action mapped to a shortcut in kitty.conf") } output := tui.KittenOutputSerializer() lp, err := loop.New() diff --git a/kittens/diff/main.py b/kittens/diff/main.py index cc1a1c4ab..9d6ed6ca4 100644 --- a/kittens/diff/main.py +++ b/kittens/diff/main.py @@ -65,6 +65,10 @@ Can be specified multiple times to use multiple patterns. For example:: ''', ) +opt('mark_moved_lines', 'yes', option_type='to_bool', long_text=''' +Highlight lines that are moved, that is removed from the left and added to the right +differently, using the :opt:`moved_bg` color.''') + opt('word_diff_mode', 'words', choices=('words', 'central'), long_text=''' The algorithm to use for highlighting which parts of changed lines differ. @@ -152,6 +156,12 @@ opt('dark_highlight_added_bg', '#31503d', option_type='to_color') opt('added_margin_bg', '#cdffd8', option_type='to_color') opt('dark_added_margin_bg', '#31503d', option_type='to_color') +opt('moved_bg', '#fffde7', option_type='to_color', long_text='Moved text backgrounds (same text that was removed in one place and added in another)') +opt('dark_moved_bg', '#003333', option_type='to_color') + +opt('moved_margin_bg', '#fff3b0', option_type='to_color') +opt('dark_moved_margin_bg', '#00495b', option_type='to_color') + opt('filler_bg', '#fafbfc', option_type='to_color', long_text='Filler (empty) line background') opt('dark_filler_bg', '#262c36', option_type='to_color') diff --git a/kittens/diff/patch.go b/kittens/diff/patch.go index 81ff508bf..18cd49d95 100644 --- a/kittens/diff/patch.go +++ b/kittens/diff/patch.go @@ -14,6 +14,7 @@ import ( "sync" parallel "github.com/kovidgoyal/go-parallel" + "github.com/kovidgoyal/kitty/tools/simdstring" "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/utils/images" "github.com/kovidgoyal/kitty/tools/utils/shlex" @@ -336,6 +337,7 @@ func (self *Hunk) finalize(left_lines, right_lines []string) error { type Patch struct { all_hunks []*Hunk largest_line_number, added_count, removed_count int + left_moved_lines, right_moved_lines *utils.Set[int] } func (self *Patch) Len() int { return len(self.all_hunks) } @@ -451,6 +453,54 @@ func (self *Patch) compute_centers(left_lines, right_lines []string) error { return nil } +// Use SIMD to efficiently find non-blank lines: a line is non-blank if it +// contains at least one character that is not a space or tab. +func is_non_blank(text string) bool { + return simdstring.NotIndexByte2String(text, ' ', '\t') >= 0 +} + +func (self *Patch) detect_moved_lines(left_lines, right_lines []string) { + // Build maps from line text to lists of line numbers for removed and added lines. + removed := make(map[string][]int, len(left_lines)) // text -> left line numbers + added := make(map[string][]int, len(right_lines)) // text -> right line numbers + for _, hunk := range self.all_hunks { + for _, chunk := range hunk.chunks { + if !chunk.is_context { + for i := 0; i < chunk.left_count; i++ { + lnum := chunk.left_start + i + text := left_lines[lnum] + if is_non_blank(text) { + removed[text] = append(removed[text], lnum) + } + } + for i := 0; i < chunk.right_count; i++ { + rnum := chunk.right_start + i + text := right_lines[rnum] + if is_non_blank(text) { + added[text] = append(added[text], rnum) + } + } + } + } + } + // Lines that appear in both removed and added sets are moved lines. When a + // line appears multiple times on each side, only min(left_count, + // right_count) occurrences are marked as moved. + self.left_moved_lines = utils.NewSet[int]() + self.right_moved_lines = utils.NewSet[int]() + for text, lnums := range removed { + if rnums, ok := added[text]; ok { + count := min(len(lnums), len(rnums)) + for _, lnum := range lnums[:count] { + self.left_moved_lines.Add(lnum) + } + for _, rnum := range rnums[:count] { + self.right_moved_lines.Add(rnum) + } + } + } +} + func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err error) { ans = &Patch{all_hunks: make([]*Hunk, 0, 32)} var current_hunk *Hunk @@ -486,6 +536,9 @@ func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err ans.largest_line_number = ans.all_hunks[len(ans.all_hunks)-1].largest_line_number } err = ans.compute_centers(left_lines, right_lines) + if err == nil && conf.Mark_moved_lines { + ans.detect_moved_lines(left_lines, right_lines) + } return } diff --git a/kittens/diff/render.go b/kittens/diff/render.go index 512445b58..9dfd7046e 100644 --- a/kittens/diff/render.go +++ b/kittens/diff/render.go @@ -40,6 +40,7 @@ type HalfScreenLine struct { marked_up_margin_text string marked_up_text string is_filler bool + is_moved bool cached_wcswidth int } @@ -81,6 +82,9 @@ func (self *LogicalLine) render_screen_line(n int, lp *loop.Loop, margin_size, c if sl.left.is_filler { left_margin = format_as_sgr.margin_filler + left_margin left_text = format_as_sgr.filler + left_text + } else if sl.left.is_moved { + left_margin = format_as_sgr.moved_margin + left_margin + left_text = format_as_sgr.moved + left_text } else { switch self.line_type { case CHANGE_LINE, IMAGE_LINE: @@ -104,6 +108,9 @@ func (self *LogicalLine) render_screen_line(n int, lp *loop.Loop, margin_size, c if sl.right.is_filler { right_margin = format_as_sgr.margin_filler + right_margin right_text = format_as_sgr.filler + right_text + } else if sl.right.is_moved { + right_margin = format_as_sgr.moved_margin + right_margin + right_text = format_as_sgr.moved + right_text } else { switch self.line_type { case CHANGE_LINE, IMAGE_LINE: @@ -156,7 +163,7 @@ func place_in(text string, sz int) string { } var format_as_sgr struct { - title, margin, added, removed, added_margin, removed_margin, filler, margin_filler, hunk_margin, hunk, selection, search string + title, margin, added, removed, added_margin, removed_margin, filler, margin_filler, hunk_margin, hunk, selection, search, moved, moved_margin string } var statusline_format, added_count_format, removed_count_format, message_format func(...any) string @@ -175,6 +182,8 @@ type ResolvedColors struct { Margin_bg style.RGBA Margin_fg style.RGBA Margin_filler_bg style.NullableColor + Moved_bg style.RGBA + Moved_margin_bg style.RGBA Removed_bg style.RGBA Removed_margin_bg style.RGBA Search_bg style.RGBA @@ -202,6 +211,8 @@ func create_formatters() { rc.Margin_bg = conf.Dark_margin_bg rc.Margin_fg = conf.Dark_margin_fg rc.Margin_filler_bg = conf.Dark_margin_filler_bg + rc.Moved_bg = conf.Dark_moved_bg + rc.Moved_margin_bg = conf.Dark_moved_margin_bg rc.Removed_bg = conf.Dark_removed_bg rc.Removed_margin_bg = conf.Dark_removed_margin_bg rc.Search_bg = conf.Dark_search_bg @@ -223,6 +234,8 @@ func create_formatters() { rc.Margin_bg = conf.Margin_bg rc.Margin_fg = conf.Margin_fg rc.Margin_filler_bg = conf.Margin_filler_bg + rc.Moved_bg = conf.Moved_bg + rc.Moved_margin_bg = conf.Moved_margin_bg rc.Removed_bg = conf.Removed_bg rc.Removed_margin_bg = conf.Removed_margin_bg rc.Search_bg = conf.Search_bg @@ -248,6 +261,8 @@ func create_formatters() { format_as_sgr.added_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Added_margin_bg.AsRGBSharp())) format_as_sgr.removed = only_open("bg=" + rc.Removed_bg.AsRGBSharp()) format_as_sgr.removed_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Removed_margin_bg.AsRGBSharp())) + format_as_sgr.moved = only_open("bg=" + rc.Moved_bg.AsRGBSharp()) + format_as_sgr.moved_margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Moved_margin_bg.AsRGBSharp())) format_as_sgr.title = only_open(fmt.Sprintf("fg=%s bg=%s bold", rc.Title_fg.AsRGBSharp(), rc.Title_bg.AsRGBSharp())) format_as_sgr.margin = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Margin_bg.AsRGBSharp())) format_as_sgr.hunk = only_open(fmt.Sprintf("fg=%s bg=%s", rc.Margin_fg.AsRGBSharp(), rc.Hunk_bg.AsRGBSharp())) @@ -533,6 +548,8 @@ type DiffData struct { available_cols, margin_size int left_lines, right_lines []string + left_moved_lines *utils.Set[int] + right_moved_lines *utils.Set[int] } func hunk_title(hunk *Hunk) string { @@ -567,7 +584,7 @@ func splitlines(text string, width int) []string { return style.WrapTextAsLines(text, width, style.WrapOptions{}) } -func render_half_line(line_number int, line, ltype string, available_cols int, center Center, ans []HalfScreenLine) []HalfScreenLine { +func render_half_line(line_number int, line, ltype string, available_cols int, center Center, is_moved bool, ans []HalfScreenLine) []HalfScreenLine { var regions []Region if ltype == "remove" { regions = center.left_regions @@ -583,7 +600,7 @@ func render_half_line(line_number int, line, ltype string, available_cols int, c } lnum := strconv.Itoa(line_number + 1) for _, sc := range splitlines(line, available_cols) { - ans = append(ans, HalfScreenLine{marked_up_margin_text: lnum, marked_up_text: sc}) + ans = append(ans, HalfScreenLine{marked_up_margin_text: lnum, marked_up_text: sc, is_moved: is_moved}) lnum = "" } return ans @@ -601,13 +618,15 @@ func lines_for_diff_chunk(data *DiffData, _ int, chunk *Chunk, _ int, ans []*Log } if i < chunk.left_count { left_lnum = chunk.left_start + i - ll = render_half_line(left_lnum, data.left_lines[left_lnum], "remove", data.available_cols, center, ll) + left_is_moved := data.left_moved_lines != nil && data.left_moved_lines.Has(left_lnum) + ll = render_half_line(left_lnum, data.left_lines[left_lnum], "remove", data.available_cols, center, left_is_moved, ll) left_lnum++ } if i < chunk.right_count { right_lnum = chunk.right_start + i - rl = render_half_line(right_lnum, data.right_lines[right_lnum], "add", data.available_cols, center, rl) + right_is_moved := data.right_moved_lines != nil && data.right_moved_lines.Has(right_lnum) + rl = render_half_line(right_lnum, data.right_lines[right_lnum], "add", data.available_cols, center, right_is_moved, rl) right_lnum++ } @@ -663,7 +682,10 @@ func lines_for_diff(left_path string, right_path string, patch *Patch, columns, return append(ans, &ht), nil } available_cols := columns/2 - margin_size - data := DiffData{left_path: left_path, right_path: right_path, available_cols: available_cols, margin_size: margin_size} + data := DiffData{ + left_path: left_path, right_path: right_path, available_cols: available_cols, margin_size: margin_size, + left_moved_lines: patch.left_moved_lines, right_moved_lines: patch.right_moved_lines, + } if left_path != "" { data.left_lines, err = highlighted_lines_for_path(left_path) if err != nil { @@ -720,7 +742,7 @@ func all_lines(path string, columns, margin_size int, is_add bool, ans []*Logica } for line_number, line := range lines { hlines := make([]HalfScreenLine, 0, 8) - hlines = render_half_line(line_number, line, ltype, available_cols, Center{}, hlines) + hlines = render_half_line(line_number, line, ltype, available_cols, Center{}, false, hlines) l := ll if is_add { l.right_reference.linenum = line_number + 1 diff --git a/kitty/boss.py b/kitty/boss.py index 2818e2174..8dc58060c 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -167,6 +167,7 @@ from .utils import ( parse_address_spec, parse_os_window_state, platform_window_id, + resolve_custom_file, safe_print, sanitize_url_for_display_to_user, shlex_split, @@ -174,7 +175,7 @@ from .utils import ( timed_debug_print, which, ) -from .window import CommandOutput, CwdRequest, Window +from .window import CommandOutput, CwdRequest, Window, global_watchers if TYPE_CHECKING: @@ -857,9 +858,11 @@ class Boss: map f1 remote_control_script /path/to/script arg1 arg2 ... - See :ref:`rc_mapping` for details. + See :ref:`rc_mapping` for details. Relative paths are resolved with respect + to the kitty config directory. ''') def remote_control_script(self, path: str, *args: str) -> None: + path = resolve_custom_file(path) path = which(path) or path if not os.access(path, os.X_OK): self.show_error('Remote control script not executable', f'The script {path} is not executable check its permissions') @@ -1236,7 +1239,7 @@ class Boss: is_password: bool = False, initial_value: str = '', window_title: str = '', - ) -> None: + ) -> Window | None: result: str = '' def callback_(res: dict[str, Any], x: int, boss: Boss) -> None: @@ -1251,9 +1254,10 @@ class Boss: cmd.append('--default=' + initial_value) if window_title: cmd.append(f'--title={window_title}') - self.run_kitten_with_metadata( + w = self.run_kitten_with_metadata( 'ask', cmd, window=window, custom_callback=callback_, default_data={'response': ''}, action_on_removal=on_popup_overlay_removal ) + return w if isinstance(w, Window) else None def get_save_filepath( self, msg: str, # can contain newlines and ANSI formatting @@ -1969,10 +1973,11 @@ class Boss: y -= central.top if tab := tm.active_tab: for window in tab: - g = window.geometry - if g.left <= x < g.right and g.top <= y < g.bottom: - window.on_drop(drop) - break + if window.is_visible_in_layout: + g = window.geometry + if g.left <= x < g.right and g.top <= y < g.bottom: + window.on_drop(drop) + break elif tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom: if (tab_id := tm.tab_bar.tab_id_at(x)) and (tab := self.tab_for_id(tab_id)) and (w := tab.active_window): w.on_drop(drop) @@ -2121,6 +2126,25 @@ class Boss: quit_confirmation_window_id: int = 0 + def _call_on_quit_watchers(self, data: dict[str, Any]) -> bool: + w = self.active_window + if w is None: + for window in self.window_id_map.values(): + w = window + break + if w is None: + return True + data['aborted'] = False + for watcher in global_watchers().on_quit: + try: + watcher(self, w, data) + except Exception: + import traceback + traceback.print_exc() + if data.get('aborted'): + return False + return True + @ac('win', 'Quit, closing all windows') def quit(self, *args: Any) -> None: windows = [] @@ -2133,6 +2157,8 @@ class Boss: num = num_active_windows if x < 0 else len(windows) needs_confirmation = x != 0 and num >= abs(x) if not needs_confirmation: + if not self._call_on_quit_watchers({'confirmed': True}): + return set_application_quit_request(IMPERATIVE_CLOSE_REQUESTED) return if current_application_quit_request() == CLOSE_BEING_CONFIRMED: @@ -2147,6 +2173,8 @@ class Boss: tab.set_active_window(w) return return + if not self._call_on_quit_watchers({'confirmed': False}): + return msg = msg or _('It has {} windows.').format(num) w = self.confirm(_('Are you sure you want to quit kitty?') + ' ' + msg, self.handle_quit_confirmation, window=active_window, title=_('Quit kitty?')) self.quit_confirmation_window_id = w.id @@ -2154,6 +2182,10 @@ class Boss: def handle_quit_confirmation(self, confirmed: bool) -> None: self.quit_confirmation_window_id = 0 + if confirmed: + if not self._call_on_quit_watchers({'confirmed': True}): + set_application_quit_request(NO_CLOSE_REQUESTED) + return set_application_quit_request(IMPERATIVE_CLOSE_REQUESTED if confirmed else NO_CLOSE_REQUESTED) def notify_on_os_window_death(self, address: str) -> None: @@ -2381,12 +2413,22 @@ class Boss: title = '' tab.set_title(title) return - prefilled = tab.name or tab.title - if title in ('" "', "' '"): - prefilled = '' - self.get_line( + if (w := self.window_id_map.get(tab.renaming_in_window)) is not None and w in tab: + tab.set_active_window(w) + return + prefilled = (tab.name or tab.title).strip() + tab_id = tab.id + + def on_rename_done(new_title: str) -> None: + if (tab := self.tab_for_id(tab_id)) is not None: + tab.renaming_in_window = 0 + tab.set_title(new_title) + + overlay_window = self.get_line( _('Enter the new title for this tab below. An empty title will cause the default title to be used.'), - tab.set_title, window=tab.active_window, initial_value=prefilled, window_title=_('Rename tab')) + on_rename_done, window=tab.active_window, initial_value=prefilled, window_title=_('Rename tab')) + if overlay_window is not None: + tab.renaming_in_window = overlay_window.id def create_special_window_for_show_error(self, title: str, msg: str, overlay_for: int | None = None) -> SpecialWindowInstance: ec = sys.exc_info() diff --git a/kitty/cocoa_window.h b/kitty/cocoa_window.h index 1779de9e2..773d576c7 100644 --- a/kitty/cocoa_window.h +++ b/kitty/cocoa_window.h @@ -67,3 +67,4 @@ void get_cocoa_key_equivalent(uint32_t, int, char *key, size_t key_sz, int*); void set_cocoa_pending_action(CocoaPendingAction action, const char*); void cocoa_report_live_notifications(const char* ident); void cocoa_set_dock_badge(const char *label); +void cocoa_clear_dock_badge_if_set(void); diff --git a/kitty/cocoa_window.m b/kitty/cocoa_window.m index ce7a7c58c..50249f785 100644 --- a/kitty/cocoa_window.m +++ b/kitty/cocoa_window.m @@ -1341,15 +1341,23 @@ cocoa_show_progress_bar_on_dock_icon(PyObject *self UNUSED, PyObject *args) { // Dock badge {{{ +static bool dock_badge_is_set = false; + void cocoa_set_dock_badge(const char *label) { @autoreleasepool { NSDockTile *dockTile = [NSApp dockTile]; [dockTile setBadgeLabel:label ? @(label) : nil]; [dockTile display]; + dock_badge_is_set = (label != NULL); } } +void +cocoa_clear_dock_badge_if_set(void) { + if (dock_badge_is_set) cocoa_set_dock_badge(NULL); +} + // }}} static PyMethodDef module_methods[] = { diff --git a/kitty/colors.c b/kitty/colors.c index 612e2dea2..b708ee32a 100644 --- a/kitty/colors.c +++ b/kitty/colors.c @@ -682,6 +682,45 @@ new_color(PyTypeObject *type UNUSED, PyObject *args, PyObject *kwds) { return (PyObject*) alloc_color(r, g, b, a); } +static PyObject * +color_vectorcall(PyObject *type UNUSED, PyObject *const *args, size_t nargsf, PyObject *kwnames) { + const Py_ssize_t nargs = PyVectorcall_NARGS(nargsf); + if (nargs > 4) { + PyErr_SetString(PyExc_TypeError, "Color() takes at most 4 arguments"); + return NULL; + } + unsigned char rgba[4] = {0, 0, 0, 0}; + for (Py_ssize_t i = 0; i < nargs; i++) { + unsigned long val = PyLong_AsUnsignedLongMask(args[i]); + if (val == (unsigned long)-1 && PyErr_Occurred()) return NULL; + rgba[i] = (unsigned char)val; + } + if (kwnames) { + const unsigned num = PyTuple_GET_SIZE(kwnames); + for (unsigned i = 0; i < num; i++) { + const char *name = PyUnicode_AsUTF8(PyTuple_GET_ITEM(kwnames, i)); + if (!name) return NULL; + int idx; +#define C(ch, i, expected) case ch: idx = i; if (strcmp(name, expected) != 0) { \ + PyErr_Format(PyExc_TypeError, "Color() got an unexpected keyword argument '%s'", name); return NULL; }; break; + switch(name[0]) { + C('r', 0, "red"); C('g', 1, "green"); C('b', 2, "blue"); C('a', 3, "alpha"); + default: + PyErr_Format(PyExc_TypeError, "Color() got an unexpected keyword argument '%s'", name); return NULL; + } +#undef C + if (idx < nargs) { + PyErr_Format(PyExc_TypeError, "Color() got multiple values for argument '%s'", name); + return NULL; + } + unsigned long val = PyLong_AsUnsignedLongMask(args[nargs + i]); + if (val == (unsigned long)-1 && PyErr_Occurred()) return NULL; + rgba[idx] = (unsigned char)val; + } + } + return (PyObject*)alloc_color(rgba[0], rgba[1], rgba[2], rgba[3]); +} + static PyObject* Color_as_int(Color *self) { return PyLong_FromUnsignedLong(self->color.val); @@ -1140,9 +1179,10 @@ PyTypeObject Color_Type = { PyVarObject_HEAD_INIT(NULL, 0) .tp_name = "kitty.fast_data_types.Color", .tp_basicsize = sizeof(Color), - .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_VECTORCALL, .tp_doc = "Color", .tp_new = new_color, + .tp_vectorcall = color_vectorcall, .tp_getset = color_getsetters, .tp_as_number = &color_number_methods, .tp_methods = color_methods, diff --git a/kitty/conf/types.py b/kitty/conf/types.py index bbc18177c..a066d3d82 100644 --- a/kitty/conf/types.py +++ b/kitty/conf/types.py @@ -9,7 +9,7 @@ from collections.abc import Callable, Iterable, Iterator from functools import lru_cache from importlib import import_module from re import Match -from typing import Any, Optional, Union, cast +from typing import Any, Optional, Sequence, Union, cast import kitty.conf.utils as generic_parsers from kitty.constants import website_url @@ -244,7 +244,7 @@ class Option: def is_color_table_color(self) -> bool: return self.name.startswith('color') and self.name[5:].isdigit() - def as_conf(self, commented: bool = False, level: int = 0, option_group: list['Option'] = []) -> list[str]: + def as_conf(self, commented: bool = False, level: int = 0, option_group: Sequence['Option'] = ()) -> list[str]: ans: list[str] = [] a = ans.append if not self.documented: @@ -264,13 +264,13 @@ class Option: def as_rst( self, conf_name: str, shortcut_slugs: dict[str, tuple[str, str]], - kitty_mod: str, level: int = 0, option_group: list['Option'] = [] + kitty_mod: str, level: int = 0, option_group: Sequence['Option'] = () ) -> list[str]: ans: list[str] = [] a = ans.append if not self.documented: return ans - mopts = [self] + option_group + mopts = [self] + list(option_group) a('.. opt:: ' + ', '.join(f'{conf_name}.{mo.name}' for mo in mopts)) if any(mo.defval_as_string for mo in mopts): a('.. code-block:: conf') diff --git a/kitty/constants.py b/kitty/constants.py index 1e3e6d71d..6ce35381a 100644 --- a/kitty/constants.py +++ b/kitty/constants.py @@ -22,7 +22,7 @@ class Version(NamedTuple): appname: str = 'kitty' kitty_face = '🐱' -version: Version = Version(0, 45, 0) +version: Version = Version(0, 46, 1) str_version: str = '.'.join(map(str, version)) _plat = sys.platform.lower() is_macos: bool = 'darwin' in _plat diff --git a/kitty/fonts/render.py b/kitty/fonts/render.py index 49220d94f..5aa88bd99 100644 --- a/kitty/fonts/render.py +++ b/kitty/fonts/render.py @@ -1,7 +1,6 @@ #!/usr/bin/env python # License: GPL v3 Copyright: 2016, Kovid Goyal -import ctypes import os import sys from collections.abc import Callable, Generator @@ -218,6 +217,7 @@ def set_font_family(opts: Options | None = None, override_font_size: float | Non if TYPE_CHECKING: + import ctypes CBufType = ctypes.Array[ctypes.c_ubyte] else: CBufType = None diff --git a/kitty/glfw.c b/kitty/glfw.c index 451cdfdd5..f13b85b07 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -518,6 +518,9 @@ update_modifier_state_on_modifier_key_event(GLFWkeyevent *ev, int key_modifier, static void key_callback(GLFWwindow *w, GLFWkeyevent *ev) { if (!set_callback_window(w)) return; +#ifdef __APPLE__ + cocoa_clear_dock_badge_if_set(); +#endif #ifndef __APPLE__ bool is_left; int key_modifier = key_to_modifier(ev->key, &is_left); @@ -554,6 +557,9 @@ cursor_enter_callback(GLFWwindow *w, int entered) { static void mouse_button_callback(GLFWwindow *w, int button, int action, int mods) { if (!set_callback_window(w)) return; +#ifdef __APPLE__ + cocoa_clear_dock_badge_if_set(); +#endif monotonic_t now = monotonic(); cursor_active_callback(now); mods_at_last_key_or_button_event = mods; @@ -680,6 +686,23 @@ is_droppable_mime(const char *mime) { return 0; } +static size_t +remove_duplicate_mimes(const char **mimes, size_t count) { + // Use simple O(n²) scan since lists are typically small + size_t new_count = 0; + for (size_t i = 0; i < count; i++) { + bool is_duplicate = false; + for (size_t j = 0; j < new_count; j++) { + if (strcmp(mimes[i], mimes[j]) == 0) { is_duplicate = true; break; } + } + if (!is_duplicate) { + if (new_count != i) SWAP(mimes[i], mimes[new_count]); + new_count++; + } + } + return new_count; +} + static void update_allowed_mimes_for_drop(GLFWDropEvent *ev) { if (ev->mimes && ev->num_mimes) { @@ -732,6 +755,7 @@ read_drop_data(GLFWwindow *window, GLFWDropEvent *ev) { PyObject *data = chunk; RAII_PyObject(existing, PyDict_GetItemString(global_state.drop_dest.data, ev->mimes[0])); if (existing) { + existing = Py_NewRef(existing); // because PyBytes_Concat steals a reference PyBytes_Concat(&existing, chunk); data = existing; } @@ -779,6 +803,7 @@ on_drop(GLFWwindow *window, GLFWDropEvent *ev) { break; } update_allowed_mimes_for_drop(ev); + ev->num_mimes = remove_duplicate_mimes(ev->mimes, ev->num_mimes); global_state.drop_dest.num_left = ev->num_mimes; if (!global_state.drop_dest.num_left || !(global_state.drop_dest.data = PyDict_New())) { ev->finish_drop(window, GLFW_DRAG_OPERATION_GENERIC); @@ -1068,6 +1093,7 @@ set_os_window_icon(PyObject UNUSED *self, PyObject *args) { if(!PyArg_ParseTuple(args, "K|O", &id, &what)) return NULL; OSWindow *os_window = os_window_for_id(id); if (!os_window) { PyErr_Format(PyExc_KeyError, "No OS Window with id: %llu", id); return NULL; } + if (os_window->is_layer_shell && global_state.is_wayland) Py_RETURN_NONE; if (!what || what == Py_None) { glfwSetWindowIcon(os_window->handle, 0, NULL); Py_RETURN_NONE; @@ -1708,7 +1734,7 @@ create_os_window(PyObject UNUSED *self, PyObject *args, PyObject *kw) { glfwCocoaSetWindowResizeCallback(glfw_window, cocoa_os_window_resized); #endif send_prerendered_sprites_for_window(w); - if (logo.pixels && logo.width && logo.height) glfwSetWindowIcon(glfw_window, 1, &logo); + if (logo.pixels && logo.width && logo.height && (!lsc || !global_state.is_wayland)) glfwSetWindowIcon(glfw_window, 1, &logo); set_glfw_mouse_pointer_shape_in_window(glfw_window, OPT(default_pointer_shape)); update_os_window_viewport(w, false); glfwSetWindowPosCallback(glfw_window, window_pos_callback); diff --git a/kitty/launch.py b/kitty/launch.py index 119a0c2c4..75401348c 100644 --- a/kitty/launch.py +++ b/kitty/launch.py @@ -548,6 +548,9 @@ def load_watch_modules(watchers: Iterable[str]) -> Watchers | None: w = m.get('on_tab_bar_dirty') if callable(w): ans.on_tab_bar_dirty.append(w) + w = m.get('on_quit') + if callable(w): + ans.on_quit.append(w) return ans diff --git a/kitty/layout/splits.py b/kitty/layout/splits.py index 40e4c6a06..245f000b2 100644 --- a/kitty/layout/splits.py +++ b/kitty/layout/splits.py @@ -502,6 +502,44 @@ class Pair: return False return self.two.is_group_on_second(gid) + def find_window_in_tree(self, window_id: int) -> 'list[tuple[Pair, bool]] | None': + # Returns list of (pair, is_in_one) from self down to the pair containing window_id. + if self.one == window_id: + return [(self, True)] + if self.two == window_id: + return [(self, False)] + if isinstance(self.one, Pair): + path = self.one.find_window_in_tree(window_id) + if path is not None: + return [(self, True)] + path + if isinstance(self.two, Pair): + path = self.two.find_window_in_tree(window_id) + if path is not None: + return [(self, False)] + path + return None + + def path_from_root(self, target: 'Pair') -> 'list[str] | None': + if self is target: + return [] + if isinstance(self.one, Pair): + sub = self.one.path_from_root(target) + if sub is not None: + return ['one'] + sub + if isinstance(self.two, Pair): + sub = self.two.path_from_root(target) + if sub is not None: + return ['two'] + sub + return None + + def pair_at_path(self, path: 'list[str]') -> 'Pair | None': + current: Pair = self + for step in path: + child = current.one if step == 'one' else current.two + if not isinstance(child, Pair): + return None + current = child + return current + class SplitsLayoutOpts(LayoutOpts): @@ -771,6 +809,46 @@ class Splits(Layout): if pair is not None: pair.set_bias(wg.id, bias) return True + elif action_name == 'maximize': + args = args or ('horizontal',) + axis = args[0] + is_horizontal = axis == 'horizontal' + wg = all_windows.active_group + if wg is not None: + key = (wg.id, is_horizontal) + maximized_biases: dict[tuple[int, bool], list[tuple[Pair, float]]] = getattr(self, '_maximized_biases', {}) + if key in maximized_biases: + # Already maximized along this axis for this window — toggle back + current_pair_ids = {id(p) for p in self.pairs_root.self_and_descendants()} + for pair_ref, saved_bias in maximized_biases.pop(key): + if id(pair_ref) in current_pair_ids: + pair_ref.bias = saved_bias + self._maximized_biases = maximized_biases + return True + else: + # Undo any existing maximize along the same axis (different window) + stale_keys = [k for k in maximized_biases if k[1] == is_horizontal] + if stale_keys: + current_pair_ids = {id(p) for p in self.pairs_root.self_and_descendants()} + for k in stale_keys: + for pair_ref, saved_bias in maximized_biases.pop(k): + if id(pair_ref) in current_pair_ids: + pair_ref.bias = saved_bias + # Maximize: set biases along the path to give maximum space to active window + # Only adjust pairs whose split axis matches the requested direction: + # horizontal maximize expands width (affects horizontal/side-by-side splits), + # vertical maximize expands height (affects vertical/top-bottom splits). + tree_path = self.pairs_root.find_window_in_tree(wg.id) + if tree_path is not None: + saved_biases: list[tuple[Pair, float]] = [] + for pair, is_in_one in tree_path: + if pair.horizontal == is_horizontal and not pair.is_redundant: + saved_biases.append((pair, pair.bias)) + pair.bias = 1.0 if is_in_one else 0.0 + if saved_biases: + maximized_biases[key] = saved_biases + self._maximized_biases = maximized_biases + return True return None @@ -840,7 +918,25 @@ class Splits(Layout): return ans def layout_state(self) -> dict[str, Any]: - return {'pairs': self.pairs_root.serialize()} + ans: dict[str, Any] = {'pairs': self.pairs_root.serialize()} + maximized_biases: dict[tuple[int, bool], list[tuple[Pair, float]]] = getattr(self, '_maximized_biases', {}) + if maximized_biases: + serialized_maximized = [] + for (window_id, is_horizontal), saved_biases_list in maximized_biases.items(): + entries = [] + for pair_ref, saved_bias in saved_biases_list: + path = self.pairs_root.path_from_root(pair_ref) + if path is not None: + entries.append({'path': path, 'bias': saved_bias}) + if entries: + serialized_maximized.append({ + 'window_id': window_id, + 'is_horizontal': is_horizontal, + 'saved_biases': entries, + }) + if serialized_maximized: + ans['maximized'] = serialized_maximized + return ans def set_layout_state(self, layout_state: dict[str, Any], map_group_id: WindowMapper) -> bool: new_root = Pair() @@ -849,5 +945,21 @@ class Splits(Layout): if before == frozenset(new_root.all_window_ids()): self.pairs_root = new_root self.layout_opts = SplitsLayoutOpts(layout_state['opts']) + if 'maximized' in layout_state: + maximized_biases: dict[tuple[int, bool], list[tuple[Pair, float]]] = {} + for entry in layout_state['maximized']: + new_window_id = map_group_id(entry['window_id']) + if new_window_id is None: + continue + is_horizontal: bool = entry['is_horizontal'] + saved_biases_list: list[tuple[Pair, float]] = [] + for saved in entry['saved_biases']: + pair = new_root.pair_at_path(saved['path']) + if pair is not None: + saved_biases_list.append((pair, saved['bias'])) + if saved_biases_list: + maximized_biases[(new_window_id, is_horizontal)] = saved_biases_list + if maximized_biases: + self._maximized_biases = maximized_biases return True return False diff --git a/kitty/mouse.c b/kitty/mouse.c index 1726e5c66..ff224dac3 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -1394,7 +1394,7 @@ pixel_scroll_enabled_for_screen(const Screen *screen) { void scroll_event(const GLFWScrollEvent *ev) { - debug("\x1b[36mScroll\x1b[m %s x: %f y: %f momentum: %s modifiers: %s\n", scroll_offset_type(ev->offset_type), ev->x_offset, ev->y_offset, scroll_phase(ev->momentum_type), format_mods(ev->keyboard_modifiers)); + debug("\x1b[36mScroll\x1b[m type=%s x: %f y: %f momentum: %s modifiers: %s\n", scroll_offset_type(ev->offset_type), ev->x_offset, ev->y_offset, scroll_phase(ev->momentum_type), format_mods(ev->keyboard_modifiers)); static id_type window_for_momentum_scroll = 0; static bool main_screen_for_momentum_scroll = false; // allow scroll events even if window is not currently focused (in @@ -1467,14 +1467,14 @@ scroll_event(const GLFWScrollEvent *ev) { write_escape_code_to_child(screen, ESC_CSI, mouse_event_buf); } } - } else { - if (screen->linebuf == screen->main_linebuf) { - screen_history_scroll(screen, abs(s), upwards); - if (screen->selections.in_progress) update_drag(w); + } else { + if (screen->linebuf == screen->main_linebuf) { + screen_history_scroll(screen, abs(s), upwards); + if (screen->selections.in_progress) update_drag(w); + } + else fake_scroll(w, abs(s), upwards); } - else fake_scroll(w, abs(s), upwards); } - } } } if (ev->x_offset != 0.0) { diff --git a/kitty/options/definition.py b/kitty/options/definition.py index f4d5a3a5c..d6dc751b0 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1805,7 +1805,9 @@ opt('tab_bar_margin_color', 'none', long_text=''' Color for the tab bar margin area. Defaults to using the terminal background color for margins above and below the tab bar. For side margins the default -color is chosen to match the background color of the neighboring tab. +color is chosen to match the background color of the neighboring tab, unless +the window is translucent, in which case the default background is used as it +looks better. ''') opt( diff --git a/kitty/state.c b/kitty/state.c index 8ab486b8c..e378dcc2a 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -1246,6 +1246,7 @@ PYWRAP0(apply_options_update) { OSWindow *os_window = global_state.os_windows + o; get_platform_dependent_config_values(os_window->handle); os_window->background_opacity.alpha = OPT(background_opacity); + set_os_window_chrome(os_window); if (!os_window->redraw_count) os_window->redraw_count++; for (size_t t = 0; t < os_window->num_tabs; t++) { Tab *tab = os_window->tabs + t; diff --git a/kitty/tab_bar.py b/kitty/tab_bar.py index 4ecd9633e..d42da165b 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -19,6 +19,7 @@ from .fast_data_types import ( Color, Region, Screen, + background_opacity_of, cell_size_for_window, get_boss, get_options, @@ -687,7 +688,7 @@ class TabBar: if opts.tab_bar_margin_height.outer: blank_rects.append(Border(0, tab_bar.bottom, vw, vh, bg)) if opts.tab_bar_margin_height.inner: - blank_rects.append(Border(0, central.bottom, vw, vh, bg)) + blank_rects.append(Border(0, central.bottom, vw, tab_bar.top, bg)) else: # top if opts.tab_bar_margin_height.outer: blank_rects.append(Border(0, 0, vw, tab_bar.top, bg)) @@ -695,13 +696,14 @@ class TabBar: blank_rects.append(Border(0, tab_bar.bottom, vw, central.top, bg)) g = self.window_geometry left_bg = right_bg = bg - if opts.tab_bar_margin_color is None or opts.tab_bar_margin_width == 0: + if opts.tab_bar_margin_color is None and ( + opacity := background_opacity_of(self.os_window_id)) is not None and opacity >= 1: left_bg = BorderColor.tab_bar_left_edge_color right_bg = BorderColor.tab_bar_right_edge_color if g.left > 0: blank_rects.append(Border(0, g.top, g.left, g.bottom, left_bg)) - if g.right - 1 < vw: - blank_rects.append(Border(g.right - 1, g.top, vw, g.bottom, right_bg)) + if g.right < vw: + blank_rects.append(Border(g.right, g.top, vw, g.bottom, right_bg)) self.blank_rects = tuple(blank_rects) def layout(self) -> None: diff --git a/kitty/tabs.py b/kitty/tabs.py index a5b4584ac..0be40ddbe 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -95,6 +95,7 @@ class TabDict(TypedDict): is_focused: bool is_active: bool title: str + title_overridden: bool layout: str layout_state: dict[str, Any] layout_opts: dict[str, Any] @@ -148,6 +149,7 @@ class Tab: # {{{ inactive_bg: int | None = None confirm_close_window_id: int = 0 force_show_title_bars: bool = False + renaming_in_window: int = 0 num_of_windows_with_progress: int = 0 total_progress: int = 0 has_indeterminate_progress: bool = False @@ -1348,8 +1350,13 @@ class TabManager: # {{{ return self.tab_for_id(self.active_tab_history[-1]) elif loc in ('left', 'right'): delta = -1 if loc == 'left' else 1 - idx = (len(tabs) + self.active_tab_idx + delta) % len(tabs) - return tabs[idx] + if (at := self.active_tab) is not None: + try: + active_idx = tabs.index(at) + except ValueError: + return None + idx = (len(tabs) + active_idx + delta) % len(tabs) + return tabs[idx] return None def goto_tab(self, tab_num: int) -> None: @@ -1393,6 +1400,7 @@ class TabManager: # {{{ 'is_focused': tab is active_tab and tab.os_window_id == current_focused_os_window_id(), 'is_active': tab is active_tab, 'title': tab.name or tab.title, + 'title_overridden': bool(tab.name), 'layout': str(tab.current_layout.name), 'layout_state': tab.current_layout.serialize(tab.windows), 'layout_opts': tab.current_layout.layout_opts.serialized(), @@ -1450,8 +1458,14 @@ class TabManager: # {{{ def move_tab(self, delta: int = 1) -> None: tabs = tuple(self.tabs_to_be_shown_in_tab_bar) if len(tabs) > 1: - idx = self.active_tab_idx - new_active_tab = tabs[(idx + len(tabs) + delta) % len(tabs)] + if (at := self.active_tab) is None: + return + try: + filtered_idx = tabs.index(at) + except ValueError: + return + new_active_tab = tabs[(filtered_idx + len(tabs) + delta) % len(tabs)] + idx = self.tabs.index(at) nidx = self.tabs.index(new_active_tab) step = 1 if idx < nidx else -1 for i in range(idx, nidx, step): diff --git a/kitty/window.py b/kitty/window.py index 567d89112..fc66af39f 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -238,6 +238,7 @@ class WindowDict(TypedDict): is_focused: bool is_active: bool title: str + title_overridden: bool pid: int | None cwd: str cmdline: list[str] @@ -305,6 +306,7 @@ class Watchers: on_cmd_startstop: list[Watcher] on_color_scheme_preference_change: list[Watcher] on_tab_bar_dirty: list[Watcher] + on_quit: list[Watcher] def __init__(self) -> None: self.on_resize = [] @@ -315,6 +317,7 @@ class Watchers: self.on_cmd_startstop = [] self.on_color_scheme_preference_change = [] self.on_tab_bar_dirty = [] + self.on_quit = [] def add(self, others: 'Watchers') -> None: def merge(base: list[Watcher], other: list[Watcher]) -> None: @@ -329,12 +332,14 @@ class Watchers: merge(self.on_cmd_startstop, others.on_cmd_startstop) merge(self.on_color_scheme_preference_change, others.on_color_scheme_preference_change) merge(self.on_tab_bar_dirty, others.on_tab_bar_dirty) + merge(self.on_quit, others.on_quit) def clear(self) -> None: del self.on_close[:], self.on_resize[:], self.on_focus_change[:] del self.on_set_user_var[:], self.on_title_change[:], self.on_cmd_startstop[:] del self.on_color_scheme_preference_change[:] del self.on_tab_bar_dirty[:] + del self.on_quit[:] def copy(self) -> 'Watchers': ans = Watchers() @@ -346,12 +351,14 @@ class Watchers: ans.on_cmd_startstop = self.on_cmd_startstop[:] ans.on_color_scheme_preference_change = self.on_color_scheme_preference_change[:] ans.on_tab_bar_dirty = self.on_tab_bar_dirty[:] + ans.on_quit = self.on_quit[:] return ans @property def has_watchers(self) -> bool: return bool(self.on_close or self.on_resize or self.on_focus_change or self.on_color_scheme_preference_change - or self.on_set_user_var or self.on_title_change or self.on_cmd_startstop or self.on_tab_bar_dirty) + or self.on_set_user_var or self.on_title_change or self.on_cmd_startstop or self.on_tab_bar_dirty + or self.on_quit) def call_watchers(windowref: Callable[[], Optional['Window']], which: str, data: dict[str, Any]) -> None: @@ -1252,7 +1259,7 @@ class Window: try: parts = tuple(map(int, raw_data.split(';')))[1:] except Exception: - log_error(f'Ignoring malmormed OSC 9;4 progress report: {raw_data!r}') + log_error(f'Ignoring malformed OSC 9;4 progress report: {raw_data!r}') return self.progress.update(*parts[:2]) if (tab := self.tabref()) is not None: @@ -2019,7 +2026,7 @@ class Window: def handle_dangerous_paste_confirmation(self, unsanitized: bytes, sanitized: bytes, choice: str) -> None: if choice == 's': self.paste_text(sanitized) - elif choice == 'p': + elif choice == 'a': self.paste_text(unsanitized) def handle_large_paste_confirmation(self, btext: bytes, confirmed: bool) -> None: @@ -2083,6 +2090,7 @@ class Window: 'is_focused': is_focused, 'is_active': is_active, 'title': self.title, + 'title_overridden': self.override_title is not None, 'pid': self.child.pid, 'cwd': self.child.current_cwd or self.child.cwd, 'cmdline': self.child.cmdline, diff --git a/kitty_tests/check_build.py b/kitty_tests/check_build.py index 086175558..dfc9ed3a6 100644 --- a/kitty_tests/check_build.py +++ b/kitty_tests/check_build.py @@ -2,10 +2,14 @@ # License: GPLv3 Copyright: 2021, Kovid Goyal +import json import os +import shutil import stat import subprocess import sys +import tempfile +import textwrap import unittest from functools import partial @@ -35,6 +39,103 @@ class TestBuild(BaseTest): for name in 'cell border bgimage tint graphics'.split(): Program(name) + def test_macos_dictation_forwarding(self) -> None: + from kitty.constants import glfw_path, is_macos + if not is_macos or not shutil.which('clang'): + self.skipTest('Dictation smoke test is macOS only and requires clang') + cocoa_module = glfw_path('cocoa') + probe = textwrap.dedent('''\ + #import + #import + #import + #import + + static int start_calls = 0; + static int stop_calls = 0; + static id last_sender = nil; + + static void fake_start_dictation(id self, SEL _cmd, id sender) { + (void)self; (void)_cmd; + start_calls++; + last_sender = sender; + } + + static void fake_stop_dictation(id self, SEL _cmd, id sender) { + (void)self; (void)_cmd; + stop_calls++; + last_sender = sender; + } + + static void require_true(BOOL condition, const char *message) { + if (!condition) { + fprintf(stderr, "FAIL: %s\\n", message); + exit(1); + } + } + + int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + void *handle = dlopen(@@COCOA_MODULE@@, RTLD_NOW | RTLD_GLOBAL); + require_true(handle != NULL, dlerror()); + + SEL start = NSSelectorFromString(@"startDictation:"); + SEL stop = NSSelectorFromString(@"stopDictation:"); + Method start_method = class_getInstanceMethod([NSApplication class], start); + Method stop_method = class_getInstanceMethod([NSApplication class], stop); + require_true(start_method != NULL, "NSApplication startDictation: missing"); + require_true(stop_method != NULL, "NSApplication stopDictation: missing"); + method_setImplementation(start_method, (IMP)fake_start_dictation); + method_setImplementation(stop_method, (IMP)fake_stop_dictation); + + Class view_cls = NSClassFromString(@"GLFWContentView"); + Class context_cls = NSClassFromString(@"GLFWTextInputContext"); + require_true(view_cls != Nil, "GLFWContentView class not loaded"); + require_true(context_cls != Nil, "GLFWTextInputContext class not loaded"); + + SEL init_with_glfw_window = NSSelectorFromString(@"initWithGlfwWindow:"); + id view = ((id (*)(id, SEL, void *)) objc_msgSend)([view_cls alloc], init_with_glfw_window, NULL); + require_true(view != nil, "GLFWContentView initWithGlfwWindow: failed"); + require_true([view respondsToSelector:start], "GLFWContentView does not expose startDictation:"); + require_true([view respondsToSelector:stop], "GLFWContentView does not expose stopDictation:"); + + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [view performSelector:start withObject:@"menu sender"]; + #pragma clang diagnostic pop + require_true(start_calls == 1, "startDictation: action was not forwarded to NSApplication"); + require_true([(id)last_sender isEqual:@"menu sender"], "startDictation: forwarded wrong sender"); + + [view doCommandBySelector:start]; + require_true(start_calls == 2, "doCommandBySelector:startDictation: was swallowed"); + require_true(last_sender == view, "doCommandBySelector:startDictation: should forward self as sender"); + + id context = [view inputContext]; + require_true(context != nil, "GLFWContentView inputContext missing"); + require_true([context isKindOfClass:context_cls], "GLFWContentView inputContext has wrong class"); + [context doCommandBySelector:stop]; + require_true(stop_calls == 1, "GLFWTextInputContext did not forward stopDictation:"); + require_true(last_sender == nil, "GLFWTextInputContext should forward nil sender"); + + printf("dictation forwarding probe passed\\n"); + } + return 0; + } + ''').replace('@@COCOA_MODULE@@', json.dumps(cocoa_module)) + with tempfile.TemporaryDirectory() as tdir: + src = os.path.join(tdir, 'dictation_probe.m') + exe = os.path.join(tdir, 'dictation_probe') + with open(src, 'w') as f: + f.write(probe) + cp = subprocess.run( + ['clang', '-framework', 'AppKit', src, '-o', exe], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + self.assertEqual(cp.returncode, 0, cp.stdout) + cp = subprocess.run([exe], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.assertEqual(cp.returncode, 0, cp.stdout) + self.assertIn('dictation forwarding probe passed', cp.stdout) + def test_glfw_modules(self) -> None: from kitty.constants import glfw_path, is_macos linux_backends = ['x11'] diff --git a/kitty_tests/datatypes.py b/kitty_tests/datatypes.py index 91497ceef..ca04e4610 100644 --- a/kitty_tests/datatypes.py +++ b/kitty_tests/datatypes.py @@ -66,6 +66,7 @@ class TestDataTypes(BaseTest): def c(spec, r=0, g=0, b=0, a=0): c = to_color(spec) self.ae(Color(r, g, b, a), c, spec) + self.ae(Color(r, green=g, alpha=a, blue=b), c, spec) c('#eee # comment', 0xee, 0xee, 0xee) c('#234567', 0x23, 0x45, 0x67) diff --git a/kitty_tests/layout.py b/kitty_tests/layout.py index 3f045c5c3..ad2c45f12 100644 --- a/kitty_tests/layout.py +++ b/kitty_tests/layout.py @@ -5,6 +5,7 @@ from kitty.config import defaults from kitty.fast_data_types import Region from kitty.layout.base import lgd from kitty.layout.interface import Grid, Horizontal, Splits, Stack, Tall +from kitty.layout.splits import Pair from kitty.types import WindowGeometry from kitty.window import EdgeWidths from kitty.window_list import WindowList, reset_group_id_counter @@ -271,3 +272,55 @@ class TestLayout(BaseTest): self.ae(q.neighbors_for_window(windows[1], all_windows), {'left': [1], 'bottom': [3, 4]}) self.ae(q.neighbors_for_window(windows[2], all_windows), {'left': [1], 'right': [4], 'top': [2]}) self.ae(q.neighbors_for_window(windows[3], all_windows), {'left': [3], 'top': [2]}) + + def test_splits_maximize(self): + q = create_layout(Splits) + all_windows = create_windows(q, num=0) + w1 = Window(1) + q.add_window(all_windows, w1) + w2 = Window(2) + q.add_window(all_windows, w2, location='vsplit') + w3 = Window(3) + q.add_window(all_windows, w3, location='hsplit') + # Layout: w1 | (w2 above w3) — horizontal split at root, vertical split on right + root = q.pairs_root + # root is horizontal, containing w1 and [w2/w3 vertical pair] + self.ae(root.horizontal, True) + right_pair = root.two if isinstance(root.two, Pair) else root.one + self.assertIsInstance(right_pair, Pair) + + # Focus window 3 (bottom-right) + all_windows.set_active_group_idx(all_windows.groups.index(all_windows.group_for_window(w3))) + + # Save original biases + root_bias_before = root.bias + right_pair_bias_before = right_pair.bias + + # maximize vertical (fill full height) — affects vertical (horizontal==False) pairs + result = q.layout_action('maximize', ('vertical',), all_windows) + self.assertTrue(result) + # right_pair is vertical (horizontal==False) so its bias should be 0.0 (w3 is in 'two') + self.ae(right_pair.bias, 0.0) + # root is horizontal so its bias should be unchanged + self.ae(root.bias, root_bias_before) + # _maximized_biases should track w3's vertical maximize + self.assertIn((all_windows.active_group.id, False), q._maximized_biases) + + # Toggle back + result = q.layout_action('maximize', ('vertical',), all_windows) + self.assertTrue(result) + self.ae(right_pair.bias, right_pair_bias_before) + self.ae(getattr(q, '_maximized_biases', {}), {}) + + # maximize horizontal (fill full width) — affects horizontal pairs + result = q.layout_action('maximize', ('horizontal',), all_windows) + self.assertTrue(result) + # root is horizontal, w3 is under root.two (right side), so bias should be 0.0 + self.ae(root.bias, 0.0) + # right_pair is vertical, so unchanged + self.ae(right_pair.bias, right_pair_bias_before) + + # Toggle back + result = q.layout_action('maximize', ('horizontal',), all_windows) + self.assertTrue(result) + self.ae(root.bias, root_bias_before) diff --git a/setup.py b/setup.py index 9ba503dd7..e810a30a7 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ def check_version_info() -> None: else: is_ok = sys.version_info > q if not is_ok: - exit(f'calibre requires Python {minver}. Current Python version: {".".join(map(str, sys.version_info[:3]))}') + exit(f'kitty requires Python {minver}. Current Python version: {".".join(map(str, sys.version_info[:3]))}') check_version_info() @@ -949,7 +949,7 @@ def add_builtin_fonts(args: Options) -> None: break else: lines = subprocess.check_output([ - 'fc-match', '--format', '%{file}\n%{postscriptname}', f'term:postscriptname={psname}', 'file', 'postscriptname']).decode().splitlines() + 'fc-list', '--format', '%{file}\n%{postscriptname}', f':postscriptname={psname}']).decode().splitlines() if len(lines) != 2: raise SystemExit(f'fc-match returned unexpected output: {lines}') if lines[1] != psname: @@ -1286,11 +1286,15 @@ def build_static_kittens( cmd = go + ['build', '-v'] vcs_rev = args.vcs_rev or get_vcs_rev() ld_flags: List[str] = [] - binary_data_flags = [f"-X kitty.VCSRevision={vcs_rev}"] + with open('go.mod') as f: + m = re.search(r'^module\s+(\S+)', f.read(), flags=re.M) + assert m is not None + modpath = m.group(1).strip() + binary_data_flags = [f"-X {modpath}.VCSRevision={vcs_rev}"] if for_freeze: - binary_data_flags.append("-X kitty.IsFrozenBuild=true") + binary_data_flags.append(f"-X {modpath}.IsFrozenBuild=true") if for_platform: - binary_data_flags.append("-X kitty.IsStandaloneBuild=true") + binary_data_flags.append(f"-X {modpath}.IsStandaloneBuild=true") if not args.debug: ld_flags.append('-s') ld_flags.append('-w') diff --git a/tools/simdstring/benchmarks_test.go b/tools/simdstring/benchmarks_test.go index 37587962b..590074102 100644 --- a/tools/simdstring/benchmarks_test.go +++ b/tools/simdstring/benchmarks_test.go @@ -64,3 +64,45 @@ func BenchmarkIndexByte2(b *testing.B) { t(pos, "scalar") } } + +func BenchmarkNotIndexByte(b *testing.B) { + t := func(pos int, which string) { + // Fill with 'a' and place 'q' (a non-matching byte) at the target position + data := haystack('a', 'q', pos) + f := NotIndexByte + switch which { + case "scalar": + f = not_index_byte_scalar + } + b.Run(fmt.Sprintf("%s_sz=%d", which, pos), func(b *testing.B) { + for b.Loop() { + f(data, 'a') + } + }) + } + for _, pos := range sizes { + t(pos, "simdstring") + t(pos, "scalar") + } +} + +func BenchmarkNotIndexByte2(b *testing.B) { + t := func(pos int, which string) { + // Fill with 'a' and place 'q' (neither 'a' nor 'x') at the target position + data := haystack('a', 'q', pos) + f := NotIndexByte2 + switch which { + case "scalar": + f = not_index_byte2_scalar + } + b.Run(fmt.Sprintf("%s_sz=%d", which, pos), func(b *testing.B) { + for b.Loop() { + f(data, 'a', 'x') + } + }) + } + for _, pos := range sizes { + t(pos, "simdstring") + t(pos, "scalar") + } +} diff --git a/tools/simdstring/generate.go b/tools/simdstring/generate.go index c0830d01c..5e7f5225f 100644 --- a/tools/simdstring/generate.go +++ b/tools/simdstring/generate.go @@ -404,6 +404,12 @@ func encode_cmgt16b(a, b, dest Register) (ans uint32) { return 0x271<<21 | b.ARMId()<<16 | 0xd<<10 | a.ARMId()<<5 | dest.ARMId() } +func encode_not16b(src, dest Register) uint32 { + // NOT Vd.16B, Vn.16B (alias of MVN) + // Encoding: 0 Q 1 01110 size 10000 00101 10 Rn Rd (Q=1, size=00 for .16B) + return 0x6E205800 | (src.ARMId() << 5) | dest.ARMId() +} + func (f *Function) MaskForCountDestructive(vec, ans Register) { // vec is clobbered by this function f.Comment("Count the number of bytes to the first 0xff byte and put the result in", ans) @@ -688,6 +694,24 @@ func (f *Function) Or(a, b, dest Register) { f.AddTrailingComment(dest, "=", a, "|", b, "(bitwise)") } +func (f *Function) NotSelf(r Register) { + if f.ISA.Goarch == ARM64 { + f.Comment("Go assembler doesn't support the VMVN instruction, below we have: NOT", r.ARMFullWidth()+",", r.ARMFullWidth()) + f.instr("WORD", fmt.Sprintf("$0x%x", encode_not16b(r, r))) + f.AddTrailingComment(r, "= ~", r, "(bitwise NOT)") + return + } + all_ones := f.Vec(r.Size) + defer f.ReleaseReg(all_ones) + f.AllOnesRegister(all_ones) + if r.Size == 128 { + f.instr("PXOR", all_ones, r) + } else { + f.instr("VPXOR", all_ones, r, r) + } + f.AddTrailingComment(r, "= ~", r, "(bitwise NOT)") +} + func (f *Function) And(a, b, dest Register) { if f.ISA.Goarch == ARM64 { f.instr("VAND", a.ARMFullWidth(), b.ARMFullWidth(), dest.ARMFullWidth()) @@ -1504,6 +1528,54 @@ func (s *State) indexc0() { } +func (s *State) not_index_byte_body(f *Function) { + b := f.Vec() + f.Set1Epi8("b", b) + test_bytes := func(bytes_to_test, test_ans Register) { + f.CmpEqEpi8(bytes_to_test, b, test_ans) + f.NotSelf(test_ans) + } + s.index_func(f, test_bytes) +} + +func (s *State) not_index_byte() { + f := s.NewFunction("not_index_byte_asm", "Find the index of the first byte that is not b", []FunctionParam{{"data", ByteSlice}, {"b", types.Byte}}, []FunctionParam{{"ans", types.Int}}) + if s.ISA.HasSIMD { + s.not_index_byte_body(f) + } + f = s.NewFunction("not_index_byte_string_asm", "Find the index of the first byte that is not b", []FunctionParam{{"data", types.String}, {"b", types.Byte}}, []FunctionParam{{"ans", types.Int}}) + if s.ISA.HasSIMD { + s.not_index_byte_body(f) + } + +} + +func (s *State) not_index_byte2_body(f *Function) { + b1 := f.Vec() + b2 := f.Vec() + f.Set1Epi8("b1", b1) + f.Set1Epi8("b2", b2) + test_bytes := func(bytes_to_test, test_ans Register) { + f.CmpEqEpi8(bytes_to_test, b1, test_ans) + f.CmpEqEpi8(bytes_to_test, b2, bytes_to_test) + f.Or(test_ans, bytes_to_test, test_ans) + f.NotSelf(test_ans) + } + s.index_func(f, test_bytes) +} + +func (s *State) not_index_byte2() { + f := s.NewFunction("not_index_byte2_asm", "Find the index of the first byte that is neither b1 nor b2", []FunctionParam{{"data", ByteSlice}, {"b1", types.Byte}, {"b2", types.Byte}}, []FunctionParam{{"ans", types.Int}}) + if s.ISA.HasSIMD { + s.not_index_byte2_body(f) + } + f = s.NewFunction("not_index_byte2_string_asm", "Find the index of the first byte that is neither b1 nor b2", []FunctionParam{{"data", types.String}, {"b1", types.Byte}, {"b2", types.Byte}}, []FunctionParam{{"ans", types.Int}}) + if s.ISA.HasSIMD { + s.not_index_byte2_body(f) + } + +} + func (s *State) Generate() { s.test_load() s.test_set1_epi8() @@ -1516,6 +1588,8 @@ func (s *State) Generate() { s.indexbyte2() s.indexc0() s.indexbyte() + s.not_index_byte() + s.not_index_byte2() s.OutputFunction() } diff --git a/tools/simdstring/intrinsics.go b/tools/simdstring/intrinsics.go index 413a9e3a3..db8e27397 100644 --- a/tools/simdstring/intrinsics.go +++ b/tools/simdstring/intrinsics.go @@ -15,8 +15,7 @@ var VectorSize = 1 // Return the index at which b first occurs in data. If not found -1 is returned. var IndexByte func(data []byte, b byte) int = index_byte_scalar -// Return the index at which either a or b first occurs in text. If neither is -// found -1 is returned. +// Return the index at which b first occurs in text. If not found -1 is returned. var IndexByteString func(text string, b byte) int = index_byte_string_scalar // Return the index at which either a or b first occurs in data. If neither is @@ -33,6 +32,18 @@ var IndexC0 func(data []byte) int = index_c0_scalar // Return the index at which the first C0 byte is found or -1 when no such bytes are present. var IndexC0String func(data string) int = index_c0_string_scalar +// Return the index of the first byte in data that is not equal to b. If all bytes equal b, -1 is returned. +var NotIndexByte func(data []byte, b byte) int = not_index_byte_scalar + +// Return the index of the first byte in text that is not equal to b. If all bytes equal b, -1 is returned. +var NotIndexByteString func(text string, b byte) int = not_index_byte_string_scalar + +// Return the index of the first byte in data that is neither a nor b. If all bytes are a or b, -1 is returned. +var NotIndexByte2 func(data []byte, a, b byte) int = not_index_byte2_scalar + +// Return the index of the first byte in text that is neither a nor b. If all bytes are a or b, -1 is returned. +var NotIndexByte2String func(text string, a, b byte) int = not_index_byte2_string_scalar + func init() { switch runtime.GOARCH { case "amd64": @@ -51,6 +62,10 @@ func init() { IndexByte2String = index_byte2_string_asm_256 IndexC0 = index_c0_asm_256 IndexC0String = index_c0_string_asm_256 + NotIndexByte = not_index_byte_asm_256 + NotIndexByteString = not_index_byte_string_asm_256 + NotIndexByte2 = not_index_byte2_asm_256 + NotIndexByte2String = not_index_byte2_string_asm_256 VectorSize = 32 } else if Have128bit { IndexByte = index_byte_asm_128 @@ -59,6 +74,10 @@ func init() { IndexByte2String = index_byte2_string_asm_128 IndexC0 = index_c0_asm_128 IndexC0String = index_c0_string_asm_128 + NotIndexByte = not_index_byte_asm_128 + NotIndexByteString = not_index_byte_string_asm_128 + NotIndexByte2 = not_index_byte2_asm_128 + NotIndexByte2String = not_index_byte2_string_asm_128 VectorSize = 16 } } diff --git a/tools/simdstring/intrinsics_test.go b/tools/simdstring/intrinsics_test.go index 23f29aec3..84e19f4e4 100644 --- a/tools/simdstring/intrinsics_test.go +++ b/tools/simdstring/intrinsics_test.go @@ -244,6 +244,65 @@ func TestSIMDStringOps(t *testing.T) { index_test([]byte("abc"), 'x') index_test([]byte("abc"), 'b') + not_index_test := func(haystack []byte, needle byte) { + var actual int + expected := not_index_byte_scalar(haystack, needle) + + for _, sz := range sizes { + switch sz { + case 16: + actual = not_index_byte_asm_128(haystack, needle) + case 32: + actual = not_index_byte_asm_256(haystack, needle) + } + if actual != expected { + t.Fatalf("not_index failed in: %#v (%d != %d) at size: %d with needle: %#v", string(haystack), expected, actual, sz, needle) + } + } + } + not_index_test(nil, 'a') + not_index_test([]byte{}, 'a') + not_index_test([]byte("aaa"), 'a') + not_index_test([]byte("aaab"), 'a') + not_index_test([]byte("baaa"), 'a') + not_index_test([]byte("abc"), 'a') + for _, sz := range []int{0, 16, 32, 64, 79} { + q := strings.Repeat("a", sz) + "b" + not_index_test([]byte(q), 'a') + not_index_test([]byte(q), 'b') + not_index_test([]byte(strings.Repeat("a", sz)), 'a') + } + + not_index2_test := func(haystack []byte, a, b byte) { + var actual int + expected := not_index_byte2_scalar(haystack, a, b) + + for _, sz := range sizes { + switch sz { + case 16: + actual = not_index_byte2_asm_128(haystack, a, b) + case 32: + actual = not_index_byte2_asm_256(haystack, a, b) + } + if actual != expected { + t.Fatalf("not_index2 failed in: %#v (%d != %d) at size: %d with needles: %#v %#v", string(haystack), expected, actual, sz, a, b) + } + } + } + not_index2_test(nil, 'a', 'b') + not_index2_test([]byte{}, 'a', 'b') + not_index2_test([]byte("aabb"), 'a', 'b') + not_index2_test([]byte("aabbc"), 'a', 'b') + not_index2_test([]byte("caabb"), 'a', 'b') + for _, sz := range []int{0, 16, 32, 64, 79} { + q := strings.Repeat("ab", sz) + "c" + not_index2_test([]byte(q), 'a', 'b') + not_index2_test([]byte(strings.Repeat("ab", sz)), 'a', 'b') + for align := range 32 { + not_index2_test([]byte(strings.Repeat(" ", align)+q), 'a', 'b') + } + } + } func TestIntrinsics(t *testing.T) { diff --git a/tools/simdstring/scalar.go b/tools/simdstring/scalar.go index 3044740ce..811bf7260 100644 --- a/tools/simdstring/scalar.go +++ b/tools/simdstring/scalar.go @@ -57,3 +57,39 @@ func index_c0_string_scalar(data string) int { } return -1 } + +func not_index_byte_scalar(data []byte, b byte) int { + for i, ch := range data { + if ch != b { + return i + } + } + return -1 +} + +func not_index_byte_string_scalar(data string, b byte) int { + for i := 0; i < len(data); i++ { + if data[i] != b { + return i + } + } + return -1 +} + +func not_index_byte2_scalar(data []byte, a, b byte) int { + for i, ch := range data { + if ch != a && ch != b { + return i + } + } + return -1 +} + +func not_index_byte2_string_scalar(data string, a, b byte) int { + for i := 0; i < len(data); i++ { + if data[i] != a && data[i] != b { + return i + } + } + return -1 +}