Add drag-and-drop quadrant preview overlay

During a window title-bar drag, render a semi-transparent tint over
the destination window to preview the drop outcome:

- Full-window tint (quadrant=5) when hovering any window in swap-based
  layouts (Tall, Stack, Fat, etc.) or over a title bar — swap is the
  result so the whole window is highlighted.
- Half-window directional tint (quadrant 1-4) when hovering in the
  Splits layout — a real directional insert happens so only the target
  half is highlighted.
- 150ms fade-in; clears immediately on drop or drag exit.

Implementation follows the visual_bell TINT_PROGRAM infrastructure:
two new Screen fields (start_drag_overlay_at, drag_overlay_quadrant),
draw_drag_preview_overlay() in shaders.c called from draw_cells() after
both render paths, set_window_drag_overlay() C→Python bridge in state.c,
and the same child-monitor.c needs_render + set_maximum_wait hooks that
visual_bell uses to keep frames firing during animation.

Layout detection uses hasattr(layout, 'insert_window_next_to') — the
same guard _insert_window_in_direction uses internally — so the overlay
always matches the actual drop behavior.
This commit is contained in:
mcrmck 2026-03-18 00:30:37 -04:00
parent 39820e79ff
commit 2c81a69aad
6 changed files with 86 additions and 20 deletions

View file

@ -811,6 +811,7 @@ prepare_to_render_os_window(OSWindow *os_window, monotonic_t now, unsigned int *
}
if (send_cell_data_to_gpu(WD.vao_idx, WD.screen, os_window)) needs_render = true;
if (WD.screen->start_visual_bell_at != 0) needs_render = true;
if (WD.screen->start_drag_overlay_at != 0) needs_render = true;
// Prepare window title bar screen data for GPU
WindowRenderData *trd = &w->window_title_render_data;
if (trd->screen && trd->geometry.bottom > trd->geometry.top && trd->geometry.right > trd->geometry.left) {
@ -877,6 +878,7 @@ render_prepared_os_window(OSWindow *os_window, unsigned int active_window_id, co
if (is_active_window) active_window = w;
draw_cells(&WD, os_window, is_active_window, false, num_of_visible_windows == 1, w);
if (WD.screen->start_visual_bell_at != 0) set_maximum_wait(ANIMATION_SAMPLE_WAIT);
if (WD.screen->start_drag_overlay_at != 0) set_maximum_wait(ANIMATION_SAMPLE_WAIT);
WindowRenderData *trd = &w->window_title_render_data;
if (trd->screen && trd->geometry.right > trd->geometry.left && trd->geometry.bottom > trd->geometry.top)
draw_cells(trd, os_window, i == tab->active_window, true, false, NULL);

View file

@ -1536,6 +1536,9 @@ def spawn(
pass
def set_window_drag_overlay(os_window_id: int, tab_id: int, window_id: int, quadrant: int) -> None: ...
def set_window_padding(os_window_id: int, tab_id: int, window_id: int, left: int, top: int, right: int, bottom: int) -> None:
pass

View file

@ -129,6 +129,8 @@ typedef struct {
ScreenModes modes, saved_modes;
ColorProfile *color_profile;
monotonic_t start_visual_bell_at;
monotonic_t start_drag_overlay_at; // 0 = inactive
uint8_t drag_overlay_quadrant; // 1=left 2=right 3=top 4=bottom 0=none
uint8_t *write_buf;
size_t write_buf_sz, write_buf_used;

View file

@ -787,6 +787,36 @@ draw_visual_bell(const UIRenderData *ui) {
#undef COLOR
}
static void
draw_drag_preview_overlay(const UIRenderData *ui) {
Screen *screen = ui->screen;
if (!screen->start_drag_overlay_at || !screen->drag_overlay_quadrant) return;
const monotonic_t elapsed = monotonic() - screen->start_drag_overlay_at;
const monotonic_t fade_ms = ms_to_monotonic_t(150ll);
float intensity = elapsed >= fade_ms ? 1.0f : (float)elapsed / (float)fade_ms;
GLfloat left = -1.f, top = 1.f, right = 1.f, bottom = -1.f;
switch (screen->drag_overlay_quadrant) {
case 1: right = 0.f; break; // left half
case 2: left = 0.f; break; // right half
case 3: bottom = 0.f; break; // top half
case 4: top = 0.f; break; // bottom half
case 5: break; // full window (swap)
default: return;
}
bind_program(TINT_PROGRAM);
float a = intensity * 0.25f;
#define COLOR(name, fallback) colorprofile_to_color_with_fallback(screen->color_profile, \
screen->color_profile->overridden.name, screen->color_profile->configured.name, \
screen->color_profile->overridden.fallback, screen->color_profile->configured.fallback)
color_type hint = !IS_SPECIAL_COLOR(highlight_bg) ? COLOR(visual_bell_color, highlight_bg) : COLOR(visual_bell_color, default_fg);
#undef COLOR
#define C(shift) (srgb_color((hint >> shift) & 0xFF) * a)
glUniform4f(tint_program_layout.uniforms.tint_color, C(16), C(8), C(0), a);
#undef C
glUniform4f(tint_program_layout.uniforms.edges, left, top, right, bottom);
draw_quad(true, 0);
}
static bool
has_scrollbar(Window *w, Screen *screen) {
if (screen->linebuf != screen->main_linebuf || !screen->historybuf->count) return false;
@ -1209,6 +1239,7 @@ draw_cells(const WindowRenderData *srd, OSWindow *os_window, bool is_active_wind
ui.screen_left, ui.screen_top, ui.screen_width, ui.screen_height, ui.full_framebuffer_height);
if (ui.os_window->needs_layers) draw_cells_with_layers(&ui, srd->vao_idx);
else draw_cells_without_layers(&ui, srd->vao_idx);
draw_drag_preview_overlay(&ui);
restore_viewport();
}
// }}}

View file

@ -864,6 +864,25 @@ PYWRAP1(set_tab_bar_render_data) {
Py_RETURN_NONE;
}
PYWRAP1(set_window_drag_overlay) {
id_type os_window_id, tab_id, window_id;
int quadrant;
PA("KKKi", &os_window_id, &tab_id, &window_id, &quadrant);
WITH_WINDOW(os_window_id, tab_id, window_id)
Screen *s = window->render_data.screen;
if (s) {
if (quadrant == 0) {
s->start_drag_overlay_at = 0;
s->drag_overlay_quadrant = 0;
} else if (s->drag_overlay_quadrant != (uint8_t)quadrant) {
s->start_drag_overlay_at = monotonic();
s->drag_overlay_quadrant = (uint8_t)quadrant;
}
}
END_WITH_WINDOW
Py_RETURN_NONE;
}
PYWRAP1(set_window_title_bar_render_data) {
WindowGeometry g = {0};
id_type os_window_id, tab_id, window_id;
@ -1632,6 +1651,7 @@ static PyMethodDef module_methods[] = {
MW(set_tab_bar_render_data, METH_VARARGS),
MW(set_window_title_bar_render_data, METH_VARARGS),
MW(set_window_render_data, METH_VARARGS),
MW(set_window_drag_overlay, METH_VARARGS),
MW(set_window_padding, METH_VARARGS),
MW(viewport_for_window, METH_VARARGS),
MW(cell_size_for_window, METH_VARARGS),

View file

@ -1124,6 +1124,7 @@ class TabBeingDropped(NamedTuple):
class WindowBeingDropped(NamedTuple):
window_id: int # the window whose title bar is currently highlighted as a drop target
quadrant: int = 0 # 0=none, 1=left, 2=right, 3=top, 4=bottom, 5=full(swap)
class TabManager: # {{{
@ -1881,21 +1882,26 @@ def _find_window_at(self, x: int, y: int) -> 'Window | None':
return win
return None
def _set_drag_target_window(self, window_id: int) -> None:
"""Highlight window_id's title bar as the drop target; 0 clears."""
def _set_drag_target_window(self, window_id: int, quadrant: int = 0) -> None:
"""Highlight window_id's title bar as the drop target; 0 clears. quadrant!=0 shows quadrant overlay instead."""
from .fast_data_types import set_window_drag_overlay
boss = get_boss()
prev_id = self.window_being_dropped.window_id if self.window_being_dropped else 0
if prev_id == window_id:
prev_quadrant = self.window_being_dropped.quadrant if self.window_being_dropped else 0
if prev_id == window_id and prev_quadrant == quadrant:
return
if prev_id and (prev_w := boss.window_id_map.get(prev_id)):
prev_w.is_drag_target = False
set_window_drag_overlay(self.os_window_id, prev_w.tab_id, prev_id, 0)
if prev_w._title_bar_screen is not None:
tab = prev_w.tabref()
prev_w.update_title_bar(is_active=tab is not None and tab.active_window is prev_w)
if window_id and (new_w := boss.window_id_map.get(window_id)):
new_w.is_drag_target = True
new_w.update_title_bar(is_active=True)
self.window_being_dropped = WindowBeingDropped(window_id=window_id)
if quadrant == 5:
new_w.is_drag_target = True
new_w.update_title_bar(is_active=True)
set_window_drag_overlay(self.os_window_id, new_w.tab_id, window_id, quadrant)
self.window_being_dropped = WindowBeingDropped(window_id=window_id, quadrant=quadrant)
else:
self.window_being_dropped = None
@ -1912,21 +1918,23 @@ def on_window_drop_move(self, window_id: int = 0, is_dest: bool = False, x: int
return
self._set_drag_target_tab(0)
dest_window = self._find_window_at(x, y)
if dest_window and dest_window.id != window_id and dest_window.show_title_bar:
from .fast_data_types import cell_size_for_window
_, ch = cell_size_for_window(self.os_window_id)
g = dest_window.geometry
opts = get_options()
tb_top = g.top if opts.window_title_bar == 'top' else g.bottom - ch
tb_bottom = tb_top + ch
from .fast_data_types import viewport_for_window
central = viewport_for_window(self.os_window_id)[0]
rel_y = y - central.top
in_title_bar = tb_top <= rel_y < tb_bottom
target_id = dest_window.id if in_title_bar else 0
if dest_window and dest_window.id != window_id:
quadrant = 5
active_tab = self.active_tab
if active_tab is not None and hasattr(active_tab.current_layout, 'insert_window_next_to'):
from .fast_data_types import viewport_for_window as _vfw
central = _vfw(self.os_window_id)[0]
rel_x = x - central.left
rel_y = y - central.top
g = dest_window.geometry
dx = rel_x - (g.left + g.right) / 2
dy = rel_y - (g.top + g.bottom) / 2
quad_map = {'left': 1, 'right': 2, 'top': 3, 'bottom': 4}
direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top')
quadrant = quad_map[direction]
self._set_drag_target_window(dest_window.id, quadrant)
else:
target_id = 0
self._set_drag_target_window(target_id)
self._set_drag_target_window(0)
def on_window_drop(self, x: int, y: int, window_id: int) -> None:
from .fast_data_types import cell_size_for_window, viewport_for_window