From d86b84800a4373af13a2ceed8399e3228eda38a9 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 23 Oct 2026 20:32:27 +0800 Subject: [PATCH] feat: reveal overflow tabs while holding a native drag at strip edges --- src/app.rs | 141 ++++++++++++++++++++++++++++++++++- src/scene.rs | 6 +- src/tabs.rs | 206 +++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 316 insertions(+), 37 deletions(-) diff --git a/src/app.rs b/src/app.rs index e8c212b..ee25d1b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, tab_drag_grab: Option, tab_drop_index: Option, + tab_first: Option, + tab_scroll_hover: Option, window_order: Vec, guest_focus: Option, 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, ¤t.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 = ¤t.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 = ¤t.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 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!( diff --git a/src/scene.rs b/src/scene.rs index 9470b13..46637bb 100644 --- a/src/scene.rs +++ b/src/scene.rs @@ -17,7 +17,7 @@ pub fn build_scene( document: &RenderDocument, developer_tools_state: &mut DeveloperToolsState, activity: &Activity, - tab_drop_index: Option, + 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, diff --git a/src/tabs.rs b/src/tabs.rs index 0655c67..67a2564 100644 --- a/src/tabs.rs +++ b/src/tabs.rs @@ -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, + pub insertion: Option, +} #[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) -> std::ops::Range { + 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, 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, + direction: i8, +) -> Option { + 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> { +fn items(start: Option, tabs: &[Tab], width: f32) -> Vec> { 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, 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 { +pub fn insertion_index( + start: Option, + tabs: &[Tab], + width: f32, + point: Point, +) -> Option { 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 .map(|index| index + 1) } -pub fn drag_anchor(tabs: &[Tab], width: f32, id: u64, point: Point) -> Option { - let item = items(tabs, width) +pub fn drag_anchor( + start: Option, + tabs: &[Tab], + width: f32, + id: u64, + point: Point, +) -> Option { + 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, + 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, 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![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 + ); } }