diff --git a/kitty/boss.py b/kitty/boss.py index 3e9de7264..2251f2d58 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -86,6 +86,7 @@ from .fast_data_types import ( get_boss, get_options, get_os_window_size, + get_tab_being_dragged, glfw_get_monitor_workarea, global_font_size, grab_keyboard, @@ -1890,6 +1891,14 @@ class Boss: if tm is not None: tm.update_tab_bar_data() + def on_drop_move(self, os_window_id: int, x: int, y: int, from_self: bool) -> None: + if (tm := self.os_window_map.get(os_window_id)) is None: + return + if from_self and (tab_id := get_tab_being_dragged()) and (tab := self.tab_for_id(tab_id)): + if (source_tm := tab.tab_manager_ref()) and (state := source_tm.tab_drag_state) and state.tab_being_dragged: + for tm in self.all_tab_managers: + tm.on_tab_drop_move(state.tab_being_dragged, x, y) + def on_drop(self, os_window_id: int, drop: dict[str, bytes] | int, from_self: bool, x: int, y: int) -> None: if isinstance(drop, int): import errno diff --git a/kitty/glfw.c b/kitty/glfw.c index 200f074bc..f5e3a11ec 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -647,6 +647,9 @@ window_focus_callback(GLFWwindow *w, int focused) { static int is_droppable_mime(const char *mime) { + static char tab_mime[64] = {0}; + if (!tab_mime[0]) snprintf(tab_mime, sizeof(tab_mime), "application/net.kovidgoyal.kitty-tab-%d", getpid()); + if (strcmp(mime, tab_mime) == 0) return 4; if (strcmp(mime, "text/uri-list") == 0) return 3; if (strcmp(mime, "text/plain;charset=utf-8") == 0) return 2; if (strcmp(mime, "text/plain") == 0) return 1; @@ -721,12 +724,18 @@ read_drop_data(GLFWwindow *window, GLFWDropEvent *ev) { static void on_drop(GLFWwindow *window, GLFWDropEvent *ev) { if (!set_callback_window(window)) return; + OSWindow *os_window = global_state.callback_os_window; switch (ev->type) { case GLFW_DROP_ENTER: case GLFW_DROP_MOVE: - global_state.callback_os_window->last_drag_event.x = (int)(ev->xpos * global_state.callback_os_window->viewport_x_ratio); - global_state.callback_os_window->last_drag_event.y = (int)(ev->ypos * global_state.callback_os_window->viewport_y_ratio); + os_window->last_drag_event.x = (int)(ev->xpos * os_window->viewport_x_ratio); + os_window->last_drag_event.y = (int)(ev->ypos * os_window->viewport_y_ratio); on_mouse_position_update(ev->xpos, ev->ypos); + if (global_state.drag_source.is_active) { + call_boss(on_drop_move, "KiiO", + os_window->id, os_window->last_drag_event.x, os_window->last_drag_event.y, + ev->from_self ? Py_True : Py_False); + } /* fallthrough */ case GLFW_DROP_STATUS_UPDATE: update_allowed_mimes_for_drop(ev); diff --git a/kitty/screenshot_fragment.glsl b/kitty/screenshot_fragment.glsl index 8d2be77b1..3fad35dc5 100644 --- a/kitty/screenshot_fragment.glsl +++ b/kitty/screenshot_fragment.glsl @@ -9,46 +9,46 @@ out vec4 output_color; void main() { // The input texture contains sRGB colors with premultiplied alpha. // We need to output unpremultiplied sRGB colors with proper downscaling. - + // For proper downscaling, we need to: // 1. Sample neighboring pixels // 2. Convert from sRGB to linear (unpremultiplying first) // 3. Average in linear space // 4. Convert back to sRGB // 5. Output unpremultiplied - + // Calculate the texel size vec2 texel_size = 1.0 / src_size; - + // Sample a 2x2 grid for better quality downscaling // This provides basic bilinear-like filtering in linear space vec2 tc = texcoord; - + vec4 s00 = texture(image, tc + vec2(-0.25, -0.25) * texel_size); vec4 s10 = texture(image, tc + vec2( 0.25, -0.25) * texel_size); vec4 s01 = texture(image, tc + vec2(-0.25, 0.25) * texel_size); vec4 s11 = texture(image, tc + vec2( 0.25, 0.25) * texel_size); - + // Unpremultiply and convert to linear for each sample vec3 linear00 = s00.a > 0.0 ? srgb2linear(s00.rgb / s00.a) : vec3(0.0); vec3 linear10 = s10.a > 0.0 ? srgb2linear(s10.rgb / s10.a) : vec3(0.0); vec3 linear01 = s01.a > 0.0 ? srgb2linear(s01.rgb / s01.a) : vec3(0.0); vec3 linear11 = s11.a > 0.0 ? srgb2linear(s11.rgb / s11.a) : vec3(0.0); - + // Average the alpha values float avg_alpha = (s00.a + s10.a + s01.a + s11.a) * 0.25; - + // For proper downsampling with transparency, weight colors by their alpha // This ensures partially transparent pixels contribute proportionally vec3 weighted_sum = linear00 * s00.a + linear10 * s10.a + linear01 * s01.a + linear11 * s11.a; float total_weight = s00.a + s10.a + s01.a + s11.a; - + // Calculate the weighted average color in linear space vec3 avg_linear = total_weight > 0.0 ? weighted_sum / total_weight : vec3(0.0); - + // Convert back to sRGB vec3 srgb_color = linear2srgb(avg_linear); - + // Output unpremultiplied sRGB color output_color = vec4(srgb_color, avg_alpha); } diff --git a/kitty/tab_bar.py b/kitty/tab_bar.py index 3cfef8203..d44f54c8a 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -40,6 +40,7 @@ class TabBarData(NamedTuple): is_active: bool needs_attention: bool tab_id: int + os_window_id: int num_windows: int num_window_groups: int layout_name: str @@ -54,6 +55,10 @@ class TabBarData(NamedTuple): session_name: str active_session_name: str + is_being_moved: bool = False + drop_idx: int = 0 + pending_drop_idx: int = -1 + class DrawData(NamedTuple): leading_spaces: int @@ -568,6 +573,7 @@ class TabBar: def __init__(self, os_window_id: int): self.os_window_id = os_window_id + self.last_laid_out_tabs: Sequence[TabBarData] = () self.num_tabs = 1 self.data_buffer_size = 0 self.blank_rects: tuple[Border, ...] = () @@ -727,6 +733,7 @@ class TabBar: s = self.screen last_tab = data[-1] if data else None ed = ExtraData() + self.last_laid_out_tabs = data def draw_tab(i: int, tab: TabBarData, cell_ranges: list[TabExtent], max_tab_length: int) -> None: ed.prev_tab = data[i - 1] if i > 0 else None diff --git a/kitty/tabs.py b/kitty/tabs.py index 8d0be338a..d66db66ca 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -396,7 +396,7 @@ class Tab: # {{{ if w.has_activity_since_last_focus: has_activity_since_last_focus = True return TabBarData( - title, is_active, needs_attention, t.id, + title, is_active, needs_attention, t.id, t.os_window_id, len(t), t.num_window_groups, t.current_layout.name or '', has_activity_since_last_focus, t.active_fg, t.active_bg, t.inactive_fg, t.inactive_bg, t.num_of_windows_with_progress, @@ -1103,6 +1103,8 @@ class TabManager: # {{{ total_progress: int = 0 has_indeterminate_progress: bool = False tab_drag_state: TabDragState | None = None + tab_being_dropped: TabBarData | None = None + last_drop_move_x: int = -1 def __init__(self, os_window_id: int, args: CLIOptions, wm_class: str, wm_name: str, startup_session: SessionType | None = None): self.os_window_id = os_window_id @@ -1194,9 +1196,13 @@ class TabManager: # {{{ @property def tab_bar_should_be_visible(self) -> bool: + if self.tab_being_dropped is not None: + return True count = get_options().tab_bar_min_tabs if count < 1: return True + if self.tab_drag_state is not None and self.tab_drag_state.drag_started: + count += 1 for t in self.tabs_to_be_shown_in_tab_bar: count -= 1 if count < 1: @@ -1272,7 +1278,7 @@ class TabManager: # {{{ f = get_options().tab_bar_filter if f: at = self.active_tab - m = set(get_boss().match_tabs(f, all_tabs=self)) + m = frozenset(get_boss().match_tabs(f, all_tabs=self)) return (t for t in self if t is at or t in m) return self.tabs @@ -1532,20 +1538,91 @@ class TabManager: # {{{ removed_tab.destroy() @property - def tab_bar_data(self) -> tuple[TabBarData, ...]: + def tab_bar_data(self) -> Sequence[TabBarData]: at = self.active_tab - return tuple(t.data_for_tab_bar(t is at) for t in self.tabs_to_be_shown_in_tab_bar) + state = self.tab_drag_state + dropped_tab_idx = dragged_tab_id = -1 + tab_being_dragged_from_here = False + if state is not None and state.drag_started: + dragged_tab_id = state.tab_id + tab_being_dragged_from_here = True + if self.tab_being_dropped is None and not tab_being_dragged_from_here: + return tuple(t.data_for_tab_bar(t is at) for t in self.tabs_to_be_shown_in_tab_bar) + if self.tab_being_dropped is not None and self.tab_being_dropped.os_window_id == self.os_window_id: + dropped_tab_idx = self.tab_being_dropped.drop_idx + if dropped_tab_idx < 0: + return tuple(t.data_for_tab_bar(t is at) for t in self.tabs_to_be_shown_in_tab_bar if t.id != dragged_tab_id) + assert self.tab_being_dropped is not None + ans: list[TabBarData] = [] + drop_idx = -1 + for i, tab in enumerate(self.tabs_to_be_shown_in_tab_bar): + if i == dropped_tab_idx: + drop_idx = len(ans) + ans.append(self.tab_being_dropped) + if tab.id != dragged_tab_id: + ans.append(tab.data_for_tab_bar(at is tab)) + if drop_idx < 0: + drop_idx = len(ans) + ans.append(self.tab_being_dropped) + if (tgt := self.tab_being_dropped.pending_drop_idx) > -1: + self.tab_being_dropped = self.tab_being_dropped._replace(pending_drop_idx=-1) + tgt = max(0, min(tgt, len(ans)-1)) + if tgt != drop_idx: + ans[drop_idx], ans[tgt] = ans[tgt], ans[drop_idx] + return ans + + def on_tab_drop_move(self, tab_data: TabBarData, x: int, y: int) -> None: + if tab_data.os_window_id == self.os_window_id: + tid = self.tab_bar.tab_id_at(x) + all_tabs = tuple(t.tab_id for t in self.tab_bar.last_laid_out_tabs) + try: + idx = all_tabs.index(tid) + except ValueError: + idx = -1 + if idx < 0: + idx = len(all_tabs) if x > 20 else 0 + if self.tab_being_dropped is None: + tab_data = tab_data._replace(drop_idx=idx) + self.last_drop_move_x = x + else: + if x == self.last_drop_move_x: + return + mouse_moved_left = x < self.last_drop_move_x + self.last_drop_move_x = x + tab_data = tab_data._replace(pending_drop_idx=idx) + cur_tab_data, self.tab_being_dropped = self.tab_being_dropped, tab_data + new_tabs = tuple(t.tab_id for t in self.tab_bar_data) + self.tab_being_dropped = cur_tab_data + if new_tabs == all_tabs: + return + try: + old_idx = all_tabs.index(tab_data.tab_id) + except Exception: + pass + else: + new_idx = new_tabs.index(tab_data.tab_id) + tab_moved_left = new_idx < old_idx + if mouse_moved_left != tab_moved_left or new_idx == old_idx: + return + tab_data = tab_data._replace(pending_drop_idx=idx) + self.tab_being_dropped = tab_data + self.layout_tab_bar() + elif self.tab_being_dropped is not None: + self.tab_being_dropped = None + self.layout_tab_bar() def start_tab_drag(self, pixels: bytes, width: int, height: int) -> None: if (state := self.tab_drag_state) is None: return for i, tab in enumerate(self.tabs_to_be_shown_in_tab_bar): if tab.id == state.tab_id: - td = tab.data_for_tab_bar(tab is self.active_tab) + td = tab.data_for_tab_bar(tab is self.active_tab)._replace( + is_being_moved=True, drop_idx=i + ) title = apply_title_template(self.tab_bar.draw_data, td, i+1) title = re.sub(r'\x1b\[.+?[a-zA-Z]', '', title).strip() # strip CSI codes + title = replace_c0_codes_except_nl_space_tab(title.encode()).decode() drag_data = { - 'text/plain': replace_c0_codes_except_nl_space_tab(title.encode()), f'application/net.kovidgoyal.kitty-tab-{os.getpid()}': str(tab.id).encode(), } start_drag_with_data(self.os_window_id, drag_data, pixels, width, height) @@ -1585,7 +1662,6 @@ class TabManager: # {{{ else: if self.tab_drag_state is None or not self.tab_drag_state.drag_started: self.set_active_tab(tab) - set_tab_being_dragged(0) elif button == GLFW_MOUSE_BUTTON_MIDDLE: if action == GLFW_RELEASE and self.recent_mouse_events: p = self.recent_mouse_events[-1]