use crate::event::Rect;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct DropGroupRect {
pub bounds: Rect,
pub tab_slots: Vec<(f32, f32)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DropEdge {
Left,
Right,
Top,
Bottom,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum DropZoneKind {
#[default]
Center,
Split(DropEdge),
TabReorder(usize),
}
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub struct DropZone {
pub kind: DropZoneKind,
pub group_idx: usize,
}
pub fn compute_drop_zone(
cursor_x: f32,
cursor_y: f32,
groups: &[DropGroupRect],
tab_bar_height: f32,
) -> Option<DropZone> {
for (gi, group) in groups.iter().enumerate() {
let b = &group.bounds;
if cursor_x < b.x
|| cursor_x >= b.x + b.width
|| cursor_y < b.y
|| cursor_y >= b.y + b.height
{
continue;
}
let in_tab_bar = cursor_y < b.y + tab_bar_height && tab_bar_height > 0.0;
if in_tab_bar && !group.tab_slots.is_empty() {
let insert_idx = tab_reorder_index(cursor_x, &group.tab_slots);
return Some(DropZone {
kind: DropZoneKind::TabReorder(insert_idx),
group_idx: gi,
});
}
let content_y = b.y + tab_bar_height;
let content_h = (b.height - tab_bar_height).max(0.0);
if content_h <= 0.0 {
return Some(DropZone {
kind: DropZoneKind::Center,
group_idx: gi,
});
}
let edge_w = edge_zone_size(b.width);
let edge_h = edge_zone_size(content_h);
let rel_x = cursor_x - b.x;
let rel_y = cursor_y - content_y;
if rel_x < edge_w {
return Some(DropZone {
kind: DropZoneKind::Split(DropEdge::Left),
group_idx: gi,
});
}
if rel_x >= b.width - edge_w {
return Some(DropZone {
kind: DropZoneKind::Split(DropEdge::Right),
group_idx: gi,
});
}
if rel_y < edge_h {
return Some(DropZone {
kind: DropZoneKind::Split(DropEdge::Top),
group_idx: gi,
});
}
if rel_y >= content_h - edge_h {
return Some(DropZone {
kind: DropZoneKind::Split(DropEdge::Bottom),
group_idx: gi,
});
}
return Some(DropZone {
kind: DropZoneKind::Center,
group_idx: gi,
});
}
None
}
#[derive(Debug, Clone, PartialEq)]
pub enum DropZoneHit {
Zone(DropZone),
Empty,
}
pub fn drop_zone_hit_test(
cursor_x: f32,
cursor_y: f32,
groups: &[DropGroupRect],
tab_bar_height: f32,
) -> DropZoneHit {
match compute_drop_zone(cursor_x, cursor_y, groups, tab_bar_height) {
Some(zone) => DropZoneHit::Zone(zone),
None => DropZoneHit::Empty,
}
}
fn tab_reorder_index(cursor_x: f32, slots: &[(f32, f32)]) -> usize {
for (i, (start, end)) in slots.iter().enumerate() {
let mid = (*start + *end) / 2.0;
if cursor_x < mid {
return i;
}
}
slots.len()
}
fn edge_zone_size(dimension: f32) -> f32 {
(dimension * 0.2).clamp(3.0, dimension / 2.0)
}
#[derive(Debug, Clone, PartialEq)]
pub struct DropOverlay {
pub highlight: Option<Rect>,
pub insertion_bar: Option<Rect>,
pub ghost_position: Option<(f32, f32)>,
}
impl DropOverlay {
pub const HIGHLIGHT_ALPHA: f32 = 0.15;
pub const MIN_BAR_THICKNESS: f32 = 2.0;
pub fn bounds(&self) -> Rect {
self.highlight.or(self.insertion_bar).unwrap_or_default()
}
}
pub fn drop_zone_overlay(
zone: &DropZone,
groups: &[DropGroupRect],
cursor_x: f32,
cursor_y: f32,
tab_bar_height: f32,
bar_thickness: f32,
ghost_offset: f32,
) -> DropOverlay {
let b = groups[zone.group_idx].bounds;
let content_y = b.y + tab_bar_height;
let content_h = (b.height - tab_bar_height).max(0.0);
match &zone.kind {
DropZoneKind::Center => DropOverlay {
highlight: Some(Rect::new(b.x, content_y, b.width, content_h)),
insertion_bar: None,
ghost_position: Some((cursor_x + ghost_offset, cursor_y)),
},
DropZoneKind::Split(dir) => {
let half_w = b.width / 2.0;
let half_h = content_h / 2.0;
let highlight = match dir {
DropEdge::Left => Rect::new(b.x, content_y, half_w, content_h),
DropEdge::Right => Rect::new(b.x + half_w, content_y, half_w, content_h),
DropEdge::Top => Rect::new(b.x, content_y, b.width, half_h),
DropEdge::Bottom => Rect::new(b.x, content_y + half_h, b.width, half_h),
};
DropOverlay {
highlight: Some(highlight),
insertion_bar: None,
ghost_position: Some((cursor_x + ghost_offset, cursor_y)),
}
}
DropZoneKind::TabReorder(idx) => {
let slots = &groups[zone.group_idx].tab_slots;
let bar_x = if *idx == 0 {
slots.first().map_or(b.x, |(s, _)| *s)
} else if *idx >= slots.len() {
slots.last().map_or(b.x, |(_, e)| *e)
} else {
let (_, prev_end) = slots[*idx - 1];
let (next_start, _) = slots[*idx];
(prev_end + next_start) / 2.0
};
DropOverlay {
highlight: None,
insertion_bar: Some(Rect::new(
bar_x - bar_thickness / 2.0,
b.y,
bar_thickness,
tab_bar_height,
)),
ghost_position: Some((cursor_x + ghost_offset, cursor_y)),
}
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::DropOverlay;
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
pub(crate) fn paint(overlay: &DropOverlay, surface: &mut dyn PaintSurface, theme: &Theme) {
if let Some(h) = overlay.highlight {
if h.width > 0.0 && h.height > 0.0 {
surface.surface_fill_rect(
h,
theme
.accent_fg
.with_alpha(DropOverlay::HIGHLIGHT_ALPHA as f64),
);
}
}
if let Some(bar) = overlay.insertion_bar {
if bar.height > 0.0 {
let widened = crate::event::Rect::new(
bar.x,
bar.y,
bar.width.max(DropOverlay::MIN_BAR_THICKNESS),
bar.height,
);
surface.surface_fill_rect(widened, theme.accent_fg);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::{Rect, Viewport};
use crate::types::Color;
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, _text: &str) -> (f32, f32) {
(0.0, 0.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, _rect: Rect, _text: &str, _color: Color) {}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
#[test]
fn highlight_paints_with_real_alpha_not_opaque() {
let overlay = DropOverlay {
highlight: Some(Rect::new(10.0, 10.0, 50.0, 30.0)),
insertion_bar: None,
ghost_position: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&overlay, &mut surface, &theme);
assert_eq!(
surface.fills.len(),
1,
"expected exactly the highlight fill"
);
let (rect, color) = surface.fills[0];
assert_eq!(rect, Rect::new(10.0, 10.0, 50.0, 30.0));
assert!(
color.a < 255,
"highlight fill must carry real alpha, got opaque a={}",
color.a
);
}
#[test]
fn insertion_bar_paints_opaque() {
let overlay = DropOverlay {
highlight: None,
insertion_bar: Some(Rect::new(40.0, 0.0, 2.0, 20.0)),
ghost_position: None,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&overlay, &mut surface, &theme);
assert_eq!(surface.fills.len(), 1, "expected exactly the bar fill");
let (_, color) = surface.fills[0];
assert_eq!(
color, theme.accent_fg,
"insertion bar is solid theme.accent_fg"
);
}
#[test]
fn insertion_bar_widens_to_min_thickness() {
let overlay = DropOverlay {
highlight: None,
insertion_bar: Some(Rect::new(40.0, 0.0, 0.0, 20.0)),
ghost_position: None,
};
let mut surface = RecordingSurface::default();
paint(&overlay, &mut surface, &Theme::default());
let (rect, _) = surface.fills[0];
assert_eq!(rect.width, DropOverlay::MIN_BAR_THICKNESS);
}
#[test]
fn zero_size_highlight_paints_nothing() {
let overlay = DropOverlay {
highlight: Some(Rect::new(10.0, 10.0, 0.0, 0.0)),
insertion_bar: None,
ghost_position: None,
};
let mut surface = RecordingSurface::default();
paint(&overlay, &mut surface, &Theme::default());
assert!(surface.fills.is_empty());
}
#[test]
fn empty_overlay_paints_nothing() {
let overlay = DropOverlay {
highlight: None,
insertion_bar: None,
ghost_position: None,
};
let mut surface = RecordingSurface::default();
paint(&overlay, &mut surface, &Theme::default());
assert!(surface.fills.is_empty());
}
#[test]
fn both_highlight_and_bar_paint_two_fills() {
let overlay = DropOverlay {
highlight: Some(Rect::new(0.0, 0.0, 20.0, 20.0)),
insertion_bar: Some(Rect::new(40.0, 0.0, 2.0, 20.0)),
ghost_position: None,
};
let mut surface = RecordingSurface::default();
paint(&overlay, &mut surface, &Theme::default());
assert_eq!(surface.fills.len(), 2);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn group(x: f32, y: f32, w: f32, h: f32, slots: &[(f32, f32)]) -> DropGroupRect {
DropGroupRect {
bounds: Rect::new(x, y, w, h),
tab_slots: slots.to_vec(),
}
}
#[test]
fn center_drop() {
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &[])];
let zone = compute_drop_zone(50.0, 50.0, &groups, 20.0).unwrap();
assert_eq!(zone.group_idx, 0);
assert_eq!(zone.kind, DropZoneKind::Center);
}
#[test]
fn left_edge_split() {
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &[])];
let zone = compute_drop_zone(5.0, 50.0, &groups, 20.0).unwrap();
assert_eq!(zone.kind, DropZoneKind::Split(DropEdge::Left));
}
#[test]
fn right_edge_split() {
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &[])];
let zone = compute_drop_zone(95.0, 50.0, &groups, 20.0).unwrap();
assert_eq!(zone.kind, DropZoneKind::Split(DropEdge::Right));
}
#[test]
fn top_edge_split() {
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &[])];
let zone = compute_drop_zone(50.0, 25.0, &groups, 20.0).unwrap();
assert_eq!(zone.kind, DropZoneKind::Split(DropEdge::Top));
}
#[test]
fn bottom_edge_split() {
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &[])];
let zone = compute_drop_zone(50.0, 75.0, &groups, 20.0).unwrap();
assert_eq!(zone.kind, DropZoneKind::Split(DropEdge::Bottom));
}
#[test]
fn tab_reorder_before_first() {
let slots = vec![(10.0, 40.0), (40.0, 70.0)];
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &slots)];
let zone = compute_drop_zone(15.0, 5.0, &groups, 20.0).unwrap();
assert_eq!(zone.kind, DropZoneKind::TabReorder(0));
}
#[test]
fn tab_reorder_between_tabs() {
let slots = vec![(10.0, 40.0), (40.0, 70.0)];
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &slots)];
let zone = compute_drop_zone(35.0, 5.0, &groups, 20.0).unwrap();
assert_eq!(zone.kind, DropZoneKind::TabReorder(1));
}
#[test]
fn tab_reorder_after_last() {
let slots = vec![(10.0, 40.0), (40.0, 70.0)];
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &slots)];
let zone = compute_drop_zone(60.0, 5.0, &groups, 20.0).unwrap();
assert_eq!(zone.kind, DropZoneKind::TabReorder(2));
}
#[test]
fn cursor_outside_all_groups() {
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &[])];
assert!(compute_drop_zone(200.0, 200.0, &groups, 20.0).is_none());
}
#[test]
fn hit_test_returns_zone_for_center_drop() {
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &[])];
match drop_zone_hit_test(50.0, 50.0, &groups, 20.0) {
DropZoneHit::Zone(zone) => {
assert_eq!(zone.kind, DropZoneKind::Center);
assert_eq!(zone.group_idx, 0);
}
DropZoneHit::Empty => panic!("expected a zone hit"),
}
}
#[test]
fn hit_test_outside_all_groups_is_empty() {
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &[])];
assert_eq!(
drop_zone_hit_test(200.0, 200.0, &groups, 20.0),
DropZoneHit::Empty
);
}
#[test]
fn multiple_groups_selects_correct() {
let groups = vec![
group(0.0, 0.0, 50.0, 80.0, &[]),
group(50.0, 0.0, 50.0, 80.0, &[]),
];
let zone = compute_drop_zone(75.0, 50.0, &groups, 20.0).unwrap();
assert_eq!(zone.group_idx, 1);
}
#[test]
fn overlay_center_covers_content_area() {
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &[])];
let zone = DropZone {
kind: DropZoneKind::Center,
group_idx: 0,
};
let ov = drop_zone_overlay(&zone, &groups, 50.0, 50.0, 20.0, 2.0, 10.0);
assert_eq!(ov.highlight, Some(Rect::new(0.0, 20.0, 100.0, 60.0)));
assert!(ov.insertion_bar.is_none());
}
#[test]
fn overlay_split_left_covers_left_half() {
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &[])];
let zone = DropZone {
kind: DropZoneKind::Split(DropEdge::Left),
group_idx: 0,
};
let ov = drop_zone_overlay(&zone, &groups, 5.0, 50.0, 20.0, 2.0, 10.0);
assert_eq!(ov.highlight, Some(Rect::new(0.0, 20.0, 50.0, 60.0)));
}
#[test]
fn overlay_tab_reorder_insertion_bar() {
let slots = vec![(10.0, 40.0), (40.0, 70.0)];
let groups = vec![group(0.0, 0.0, 100.0, 80.0, &slots)];
let zone = DropZone {
kind: DropZoneKind::TabReorder(1),
group_idx: 0,
};
let ov = drop_zone_overlay(&zone, &groups, 35.0, 5.0, 20.0, 2.0, 10.0);
assert!(ov.highlight.is_none());
let bar = ov.insertion_bar.unwrap();
assert_eq!(bar.x, 39.0); assert_eq!(bar.width, 2.0);
assert_eq!(bar.height, 20.0);
}
#[test]
fn bounds_prefers_highlight_then_insertion_bar_then_default() {
let highlight = Rect::new(1.0, 2.0, 3.0, 4.0);
let bar = Rect::new(5.0, 6.0, 7.0, 8.0);
let both = DropOverlay {
highlight: Some(highlight),
insertion_bar: Some(bar),
ghost_position: None,
};
assert_eq!(both.bounds(), highlight);
let bar_only = DropOverlay {
highlight: None,
insertion_bar: Some(bar),
ghost_position: None,
};
assert_eq!(bar_only.bounds(), bar);
let neither = DropOverlay {
highlight: None,
insertion_bar: None,
ghost_position: None,
};
assert_eq!(neither.bounds(), Rect::default());
}
}