feat: reveal overflow tabs while holding a native drag at strip edges

This commit is contained in:
2026-10-23 20:32:27 +08:00
parent c9eb498932
commit d86b84800a
3 changed files with 316 additions and 37 deletions
+137 -4
View File
@@ -1,6 +1,7 @@
use std::{
num::NonZeroU32,
sync::{Arc, RwLock},
time::{Duration, Instant},
};
use softbuffer::{Context, Surface};
@@ -8,7 +9,7 @@ use winit::{
application::ApplicationHandler,
dpi::{LogicalPosition, LogicalSize, PhysicalPosition},
event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
event_loop::{ActiveEventLoop, EventLoopProxy},
event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
keyboard::{Key, ModifiersState, NamedKey},
window::{Window, WindowAttributes, WindowId},
};
@@ -29,6 +30,14 @@ pub enum AppEvent {
ConsoleFailed { message: String, source: String },
}
struct TabScrollHover {
window: u64,
session: u64,
point: Point,
direction: i8,
next: Instant,
}
pub struct TesseraApp {
resource: u64,
native_session: u64,
@@ -50,6 +59,8 @@ pub struct TesseraApp {
tab_drag_origin: Option<Point>,
tab_drag_grab: Option<Point>,
tab_drop_index: Option<usize>,
tab_first: Option<usize>,
tab_scroll_hover: Option<TabScrollHover>,
window_order: Vec<u64>,
guest_focus: Option<crate::events::Input>,
guest_key: Option<(NamedKey, crate::events::Input)>,
@@ -88,6 +99,8 @@ impl TesseraApp {
tab_drag_origin: None,
tab_drag_grab: None,
tab_drop_index: None,
tab_first: None,
tab_scroll_hover: None,
window_order: vec![crate::window::MAIN_WINDOW],
guest_focus: None,
guest_key: None,
@@ -285,6 +298,8 @@ impl TesseraApp {
self.tab_drag_origin = None;
self.tab_drag_grab = None;
self.tab_drop_index = None;
self.tab_first = None;
self.tab_scroll_hover = None;
}
self.guest_focus =
frame
@@ -486,7 +501,7 @@ impl TesseraApp {
return None;
}
let width = window.inner_size().width as f32 / window.scale_factor() as f32;
let (id, close) = crate::tabs::hit(&frame.tabs, width, self.pointer)?;
let (id, close) = crate::tabs::hit(self.tab_first, &frame.tabs, width, self.pointer)?;
Some(crate::events::Input {
window: self.resource,
session: frame.session,
@@ -585,6 +600,7 @@ impl TesseraApp {
let window = self.window.as_ref()?;
let current = self.namespace.read().ok()?;
crate::tabs::drag_anchor(
self.tab_first,
&current.document.frame.tabs,
window.inner_size().width as f32 / window.scale_factor() as f32,
input.mount,
@@ -605,6 +621,78 @@ impl TesseraApp {
}
}
fn clear_tab_scroll(&mut self) {
self.tab_scroll_hover = None;
if self.tab_first.take().is_some() {
self.request_redraw();
}
for child in self.children.values_mut() {
child.clear_tab_scroll();
}
}
fn tick_tab_scroll(&mut self, event_loop: &ActiveEventLoop) {
let Some(mut hover) = self.tab_scroll_hover.take() else {
return;
};
let dragging = self.apps().any(|app| {
app.tab_drag_origin.is_some()
&& app.tab_pressed.as_ref().is_some_and(|input| {
input.session == hover.session
&& app.namespace.read().ok().is_some_and(|current| {
let frame = &current.document.frame;
frame.session == input.session
&& frame.fault.is_none()
&& frame.tabs.iter().any(|tab| tab.id == input.mount)
})
})
});
if !dragging {
self.clear_tab_scroll();
return;
}
let now = Instant::now();
if now < hover.next {
event_loop.set_control_flow(ControlFlow::WaitUntil(hover.next));
self.tab_scroll_hover = Some(hover);
return;
}
let target = if self.resource == hover.window {
Some(&mut *self)
} else {
self.children.get_mut(&hover.window)
};
let feedback = target.and_then(|target| {
let window = target.window.as_ref()?;
let width = window.inner_size().width as f32 / window.scale_factor() as f32;
let current = target.namespace.read().ok()?;
let frame = &current.document.frame;
if frame.session != hover.session
|| frame.fault.is_some()
|| target.guest_covered(hover.point)
{
return None;
}
let direction =
crate::tabs::edge_direction(&frame.tabs, width, target.tab_first, hover.point);
if direction != hover.direction {
return None;
}
let first = crate::tabs::scroll_start(&frame.tabs, width, target.tab_first, direction)?;
let index = crate::tabs::insertion_index(Some(first), &frame.tabs, width, hover.point)?;
drop(current);
target.tab_first = Some(first);
target.request_redraw();
Some((hover.window, index))
});
if let Some(feedback) = feedback {
self.clear_drag_feedback(Some(feedback));
hover.next = now + Duration::from_millis(120);
event_loop.set_control_flow(ControlFlow::WaitUntil(hover.next));
self.tab_scroll_hover = Some(hover);
}
}
fn native_tab_drag(&mut self, window_id: WindowId, event: &WindowEvent) {
if self.resource != crate::window::MAIN_WINDOW {
return;
@@ -617,6 +705,17 @@ impl TesseraApp {
return;
};
let receiver_id = receiver.resource;
if matches!(event, WindowEvent::CursorLeft { .. }) {
if self
.tab_scroll_hover
.as_ref()
.is_some_and(|hover| hover.window == receiver_id)
{
self.tab_scroll_hover = None;
self.clear_drag_feedback(None);
}
return;
}
if matches!(
event,
WindowEvent::Focused(true)
@@ -647,6 +746,7 @@ impl TesseraApp {
child.tab_drag_grab = None;
}
self.clear_drag_feedback(None);
self.clear_tab_scroll();
return;
}
let release = matches!(
@@ -728,6 +828,7 @@ impl TesseraApp {
child.tab_drag_grab = None;
}
self.clear_drag_feedback(None);
self.clear_tab_scroll();
return;
}
let mut apps: Vec<_> = self.apps().collect();
@@ -771,6 +872,7 @@ impl TesseraApp {
});
// An obscuring window (including its chrome/developer panel) prevents
// dropping into a lower window that happens to share these coordinates.
let mut scroll = None;
let target = front.and_then(|(app, relative, size, inside)| {
if !inside || app.guest_covered(relative) {
return None;
@@ -783,8 +885,13 @@ impl TesseraApp {
let index = if frame.tabs.is_empty() {
0
} else {
crate::tabs::insertion_index(&frame.tabs, size.width, relative)?
crate::tabs::insertion_index(app.tab_first, &frame.tabs, size.width, relative)?
};
let direction =
crate::tabs::edge_direction(&frame.tabs, size.width, app.tab_first, relative);
if direction != 0 {
scroll = Some((app.resource, frame.session, relative, direction));
}
Some(crate::events::TabDrop {
window: app.resource,
layout_version: frame.layout_version,
@@ -802,6 +909,23 @@ impl TesseraApp {
.all(|value| value.is_finite() && value.abs() <= 1_000_000.0)
.then_some([x.round() as i32, y.round() as i32])
});
self.tab_scroll_hover = scroll.map(|(window, session, point, direction)| {
let next = self
.tab_scroll_hover
.as_ref()
.filter(|old| {
old.window == window && old.session == session && old.direction == direction
})
.map(|old| old.next)
.unwrap_or_else(|| Instant::now() + Duration::from_millis(350));
TabScrollHover {
window,
session,
point,
direction,
next,
}
});
self.clear_drag_feedback(target.as_ref().map(|target| (target.window, target.index)));
if !release {
return;
@@ -829,6 +953,7 @@ impl TesseraApp {
source.guest_pressed = None;
source.send_guest(input);
self.clear_drag_feedback(None);
self.clear_tab_scroll();
}
fn create_native(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> {
@@ -914,7 +1039,10 @@ impl TesseraApp {
&namespace.document,
&mut self.developer_tools,
&self.activity,
self.tab_drop_index,
crate::tabs::Chrome {
first: self.tab_first,
insertion: self.tab_drop_index,
},
);
drop(namespace);
self.layout = scene.layout;
@@ -951,6 +1079,11 @@ impl TesseraApp {
}
impl ApplicationHandler<AppEvent> for TesseraApp {
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
event_loop.set_control_flow(ControlFlow::Wait);
self.tick_tab_scroll(event_loop);
}
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if let Err(error) = self.create_native(event_loop) {
self.native_error = Some(format!(
+4 -2
View File
@@ -17,7 +17,7 @@ pub fn build_scene(
document: &RenderDocument,
developer_tools_state: &mut DeveloperToolsState,
activity: &Activity,
tab_drop_index: Option<usize>,
tab_chrome: crate::tabs::Chrome,
) -> DisplayScene {
let mut display_list = document.display_list(scale).unwrap_or_else(|_| {
RenderDocument::default()
@@ -27,13 +27,15 @@ pub fn build_scene(
let offset = crate::tabs::height(&document.frame.tabs);
display_list.translate(crate::geometry::Point::new(0.0, offset * scale));
crate::tabs::draw(
tab_chrome.first,
&mut display_list,
&document.frame.tabs,
width as f32 / scale,
scale,
);
if let Some(index) = tab_drop_index {
if let Some(index) = tab_chrome.insertion {
crate::tabs::draw_insertion(
tab_chrome.first,
&mut display_list,
&document.frame.tabs,
width as f32 / scale,
+175 -31
View File
@@ -7,6 +7,11 @@ use crate::{
use serde::{Deserialize, Serialize};
pub const HEIGHT: f32 = 36.0;
#[derive(Clone, Copy, Default)]
pub struct Chrome {
pub first: Option<usize>,
pub insertion: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tab {
pub id: u64,
@@ -18,24 +23,62 @@ pub fn height(tabs: &[Tab]) -> f32 {
if tabs.is_empty() { 0.0 } else { HEIGHT }
}
/// A drag viewport is presentation state only: it never activates a Page.
fn visible_range(tabs: &[Tab], width: f32, start: Option<usize>) -> std::ops::Range<usize> {
let count = (((width - 64.0) / 160.0).floor().max(1.0) as usize).min(tabs.len());
if tabs.len() <= count {
return 0..tabs.len();
}
let active = tabs.iter().position(|tab| tab.active).unwrap_or(0);
let first = start
.unwrap_or_else(|| active.saturating_sub(count / 2))
.min(tabs.len() - count);
first..first + count
}
pub fn edge_direction(tabs: &[Tab], width: f32, start: Option<usize>, point: Point) -> i8 {
if point.x < 0.0 || point.x >= width || point.y < 0.0 || point.y >= HEIGHT {
return 0;
}
let visible = visible_range(tabs, width, start);
if point.x < 32.0 && visible.start > 0 {
-1
} else if point.x >= width - 32.0 && visible.end < tabs.len() {
1
} else {
0
}
}
pub fn scroll_start(
tabs: &[Tab],
width: f32,
start: Option<usize>,
direction: i8,
) -> Option<usize> {
let visible = visible_range(tabs, width, start);
if direction < 0 && visible.start > 0 {
Some(visible.start - 1)
} else if direction > 0 && visible.end < tabs.len() {
Some(visible.start + 1)
} else {
None
}
}
struct Item<'a> {
tab: &'a Tab,
rect: Rect,
navigation: Option<&'static str>,
}
fn items(tabs: &[Tab], width: f32) -> Vec<Item<'_>> {
fn items(start: Option<usize>, tabs: &[Tab], width: f32) -> Vec<Item<'_>> {
if tabs.is_empty() || width <= 0.0 {
return Vec::new();
}
let capacity = ((width - 64.0) / 160.0).floor().max(1.0) as usize;
let overflow = tabs.len() > capacity;
let count = if overflow { capacity } else { tabs.len() };
let active = tabs.iter().position(|tab| tab.active).unwrap_or(0);
let start = if overflow {
active.saturating_sub(count / 2).min(tabs.len() - count)
} else {
0
};
let visible = visible_range(tabs, width, start);
let start = visible.start;
let count = visible.len();
let overflow = count < tabs.len();
let margin = if overflow { 32.0 } else { 0.0 };
let tab_width = ((width - margin * 2.0) / count as f32).min(220.0);
let mut result = Vec::new();
@@ -66,11 +109,11 @@ fn close_rect(rect: Rect) -> Rect {
Rect::new(rect.x + rect.width - 27.0, 5.0, 24.0, 26.0)
}
pub fn hit(tabs: &[Tab], width: f32, point: Point) -> Option<(u64, bool)> {
pub fn hit(start: Option<usize>, tabs: &[Tab], width: f32, point: Point) -> Option<(u64, bool)> {
if point.x < 0.0 || point.x >= width || point.y < 0.0 || point.y >= height(tabs) {
return None;
}
items(tabs, width)
items(start, tabs, width)
.into_iter()
.find(|item| item.rect.contains(point))
.map(|item| {
@@ -82,14 +125,19 @@ pub fn hit(tabs: &[Tab], width: f32, point: Point) -> Option<(u64, bool)> {
}
/// Gap index in the complete published order, including overflow-hidden tabs.
pub fn insertion_index(tabs: &[Tab], width: f32, point: Point) -> Option<usize> {
pub fn insertion_index(
start: Option<usize>,
tabs: &[Tab],
width: f32,
point: Point,
) -> Option<usize> {
if point.x < 0.0 || point.x >= width || point.y < 0.0 || point.y >= HEIGHT {
return None;
}
if tabs.is_empty() {
return Some(0);
}
let visible: Vec<_> = items(tabs, width)
let visible: Vec<_> = items(start, tabs, width)
.into_iter()
.filter(|item| item.navigation.is_none())
.collect();
@@ -104,15 +152,28 @@ pub fn insertion_index(tabs: &[Tab], width: f32, point: Point) -> Option<usize>
.map(|index| index + 1)
}
pub fn drag_anchor(tabs: &[Tab], width: f32, id: u64, point: Point) -> Option<Point> {
let item = items(tabs, width)
pub fn drag_anchor(
start: Option<usize>,
tabs: &[Tab],
width: f32,
id: u64,
point: Point,
) -> Option<Point> {
let item = items(start, tabs, width)
.into_iter()
.find(|item| item.tab.id == id && item.navigation.is_none() && item.rect.contains(point))?;
(!close_rect(item.rect).contains(point)).then(|| Point::new(point.x - item.rect.x, point.y))
}
pub fn draw_insertion(list: &mut DisplayList, tabs: &[Tab], width: f32, index: usize, scale: f32) {
let visible: Vec<_> = items(tabs, width)
pub fn draw_insertion(
start: Option<usize>,
list: &mut DisplayList,
tabs: &[Tab],
width: f32,
index: usize,
scale: f32,
) {
let visible: Vec<_> = items(start, tabs, width)
.into_iter()
.filter(|item| item.navigation.is_none())
.collect();
@@ -146,7 +207,7 @@ pub fn draw_insertion(list: &mut DisplayList, tabs: &[Tab], width: f32, index: u
}
}
pub fn draw(list: &mut DisplayList, tabs: &[Tab], width: f32, scale: f32) {
pub fn draw(start: Option<usize>, list: &mut DisplayList, tabs: &[Tab], width: f32, scale: f32) {
if tabs.is_empty() {
return;
}
@@ -163,7 +224,7 @@ pub fn draw(list: &mut DisplayList, tabs: &[Tab], width: f32, scale: f32) {
radius: 0.0,
color: Color::rgb(230, 232, 236),
});
for item in items(tabs, width) {
for item in items(start, tabs, width) {
if item.tab.active && item.navigation.is_none() {
list.push(DrawCommand::FillRoundedRect {
rect: scaled(item.rect),
@@ -221,25 +282,28 @@ mod tests {
})
.collect();
assert_eq!(
insertion_index(&tabs, 1000.0, Point::new(10.0, 18.0)),
insertion_index(None, &tabs, 1000.0, Point::new(10.0, 18.0)),
Some(0)
);
assert_eq!(
insertion_index(&tabs, 1000.0, Point::new(215.0, 18.0)),
insertion_index(None, &tabs, 1000.0, Point::new(215.0, 18.0)),
Some(1)
);
assert_eq!(
insertion_index(&tabs, 1000.0, Point::new(990.0, 18.0)),
insertion_index(None, &tabs, 1000.0, Point::new(990.0, 18.0)),
Some(4)
);
assert!(drag_anchor(&tabs, 1000.0, 1, Point::new(200.0, 18.0)).is_none());
assert!(drag_anchor(&tabs, 1000.0, 1, Point::new(25.0, 18.0)).is_some());
assert!(drag_anchor(None, &tabs, 1000.0, 1, Point::new(200.0, 18.0)).is_none());
assert!(drag_anchor(None, &tabs, 1000.0, 1, Point::new(25.0, 18.0)).is_some());
assert_eq!(
insertion_index(&tabs, 1000.0, Point::new(20.0, HEIGHT)),
insertion_index(None, &tabs, 1000.0, Point::new(20.0, HEIGHT)),
None
);
assert!(drag_anchor(&tabs, 400.0, 3, Point::new(390.0, 18.0)).is_none());
assert_eq!(insertion_index(&[], 720.0, Point::new(20.0, 18.0)), Some(0));
assert!(drag_anchor(None, &tabs, 400.0, 3, Point::new(390.0, 18.0)).is_none());
assert_eq!(
insertion_index(None, &[], 720.0, Point::new(20.0, 18.0)),
Some(0)
);
}
#[test]
@@ -251,7 +315,7 @@ mod tests {
active: id == 32,
})
.collect();
let visible = items(&tabs, 720.0);
let visible = items(None, &tabs, 720.0);
assert!(
visible
.iter()
@@ -259,17 +323,97 @@ mod tests {
);
for item in &visible {
let point = Point::new(item.rect.x + 12.0, 18.0);
assert_eq!(hit(&tabs, 720.0, point), Some((item.tab.id, false)));
assert_eq!(hit(None, &tabs, 720.0, point), Some((item.tab.id, false)));
}
let tab = visible.iter().find(|item| item.tab.active).unwrap();
assert_eq!(
hit(
None,
&tabs,
720.0,
Point::new(tab.rect.x + tab.rect.width - 15.0, 18.0)
),
Some((32, true))
);
assert_eq!(hit(&tabs, 720.0, Point::new(50.0, HEIGHT)), None);
assert_eq!(hit(None, &tabs, 720.0, Point::new(50.0, HEIGHT)), None);
}
#[test]
fn drag_viewport_reaches_hidden_ends_without_changing_active_page() {
let tabs: Vec<_> = (1..=64)
.map(|id| Tab {
id,
title: id.to_string(),
active: id == 32,
})
.collect();
let width = 720.0;
let mut start = None;
while let Some(next) = scroll_start(&tabs, width, start, 1) {
start = Some(next);
}
assert_eq!(visible_range(&tabs, width, start), 60..64);
assert_eq!(
edge_direction(&tabs, width, start, Point::new(710.0, 18.0)),
0
);
assert_eq!(
insertion_index(start, &tabs, width, Point::new(710.0, 18.0)),
Some(64)
);
while let Some(next) = scroll_start(&tabs, width, start, -1) {
start = Some(next);
}
assert_eq!(visible_range(&tabs, width, start), 0..4);
assert_eq!(
edge_direction(&tabs, width, start, Point::new(10.0, 18.0)),
0
);
assert_eq!(
insertion_index(start, &tabs, width, Point::new(10.0, 18.0)),
Some(0)
);
assert_eq!(
tabs.iter()
.filter(|tab| tab.active)
.map(|tab| tab.id)
.collect::<Vec<_>>(),
vec![32]
);
assert!(
items(None, &tabs, width)
.iter()
.any(|item| item.tab.active && item.navigation.is_none())
);
}
#[test]
fn scrolled_hit_and_gap_geometry_survive_resize_and_list_shrink() {
let tabs: Vec<_> = (1..=8)
.map(|id| Tab {
id,
title: id.to_string(),
active: id == 1,
})
.collect();
let start = Some(4);
assert_eq!(
hit(start, &tabs, 720.0, Point::new(40.0, 18.0)),
Some((5, false))
);
assert_eq!(
insertion_index(start, &tabs, 720.0, Point::new(40.0, 18.0)),
Some(4)
);
assert_eq!(visible_range(&tabs[..3], 720.0, start), 0..3);
assert_eq!(visible_range(&tabs, 1920.0, start), 0..8);
assert_eq!(
edge_direction(&tabs, 720.0, start, Point::new(710.0, HEIGHT)),
0
);
assert_eq!(
edge_direction(&tabs, 720.0, start, Point::new(725.0, 18.0)),
0
);
}
}