680 lines
21 KiB
Rust
680 lines
21 KiB
Rust
//! Immutable tab chrome. IDs are session-local projections, never heap handles.
|
||
use crate::{
|
||
display_list::{Color, DisplayList, DrawCommand},
|
||
geometry::{Point, Rect},
|
||
text::TextStyle,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
mod hover;
|
||
mod motion;
|
||
pub use motion::{Motion, Visual};
|
||
|
||
// Native traffic lights and all tab controls share this row's center line.
|
||
pub const HEIGHT: f32 = 40.0;
|
||
pub const INSET: f32 = 8.0;
|
||
const NEW_TAB_SPACE: f32 = 44.0;
|
||
const MIN_WIDTH: f32 = 88.0;
|
||
pub const RAIL: f32 = 56.0;
|
||
pub const CONTROLS: f32 = if cfg!(target_os = "macos") { 86.0 } else { 0.0 };
|
||
pub const LEADING: f32 = CONTROLS + 12.0;
|
||
#[derive(Clone, Default)]
|
||
pub struct Chrome {
|
||
pub offset: Option<f32>,
|
||
pub insertion: Option<usize>,
|
||
pub pointer: Option<Point>,
|
||
pub visual: Visual,
|
||
}
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct Tab {
|
||
pub id: u64,
|
||
pub title: String,
|
||
pub active: bool,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub developer_tool: Option<String>,
|
||
}
|
||
|
||
pub fn height(tabs: &[Tab]) -> f32 {
|
||
let _ = tabs;
|
||
HEIGHT
|
||
}
|
||
|
||
/// Scroll offsets are logical pixels, never a discrete page index.
|
||
fn viewport(width: f32) -> Rect {
|
||
Rect::new(
|
||
LEADING,
|
||
0.0,
|
||
(width - NEW_TAB_SPACE - LEADING).max(0.0),
|
||
HEIGHT,
|
||
)
|
||
}
|
||
fn tab_width(tabs: &[Tab], width: f32) -> f32 {
|
||
(viewport(width).width / tabs.len().max(1) as f32)
|
||
.clamp(MIN_WIDTH, 208.0)
|
||
.min(viewport(width).width)
|
||
}
|
||
fn scroll_offset(tabs: &[Tab], width: f32, start: Option<f32>) -> f32 {
|
||
let tab_width = tab_width(tabs, width);
|
||
let available = viewport(width).width;
|
||
let maximum = (tabs.len() as f32 * tab_width - available).max(0.0);
|
||
let active = tabs.iter().position(|tab| tab.active).unwrap_or(0);
|
||
start
|
||
.unwrap_or(((active + 1) as f32 * tab_width - available).max(0.0))
|
||
.clamp(0.0, maximum)
|
||
}
|
||
#[cfg(test)]
|
||
fn visible_range(tabs: &[Tab], width: f32, start: Option<f32>) -> std::ops::Range<usize> {
|
||
let tab_width = tab_width(tabs, width);
|
||
if tab_width <= 0.0 {
|
||
return 0..0;
|
||
}
|
||
let offset = scroll_offset(tabs, width, start);
|
||
(offset / tab_width).floor() as usize
|
||
..(((offset + viewport(width).width) / tab_width).ceil() as usize).min(tabs.len())
|
||
}
|
||
|
||
/// Invisible drag edge zones only; ordinary clicks never scroll or activate another tab.
|
||
pub fn edge_direction(tabs: &[Tab], width: f32, start: Option<f32>, point: Point) -> i8 {
|
||
if !viewport(width).contains(point) || point.y >= HEIGHT {
|
||
return 0;
|
||
}
|
||
let offset = scroll_offset(tabs, width, start);
|
||
let maximum = (tabs.len() as f32 * tab_width(tabs, width) - viewport(width).width).max(0.0);
|
||
if point.x < LEADING + 24.0 && offset > 0.0 {
|
||
-1
|
||
} else if point.x > width - NEW_TAB_SPACE - 24.0 && offset < maximum {
|
||
1
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
pub fn wheel_offset(tabs: &[Tab], width: f32, start: Option<f32>, delta: f32) -> f32 {
|
||
scroll_offset(tabs, width, Some(scroll_offset(tabs, width, start) + delta))
|
||
}
|
||
pub fn scroll_start(tabs: &[Tab], width: f32, start: Option<f32>, direction: i8) -> Option<f32> {
|
||
let current = scroll_offset(tabs, width, start);
|
||
let next = wheel_offset(tabs, width, Some(current), direction as f32 * MIN_WIDTH);
|
||
(next != current).then_some(next)
|
||
}
|
||
struct Item<'a> {
|
||
tab: &'a Tab,
|
||
rect: Rect,
|
||
}
|
||
/// Retain all IDs, including clipped tabs, for continuous movement through the viewport.
|
||
fn tab_rects(start: Option<f32>, tabs: &[Tab], width: f32) -> Vec<(u64, Rect)> {
|
||
let tab_width = tab_width(tabs, width);
|
||
let offset = scroll_offset(tabs, width, start);
|
||
tabs.iter()
|
||
.enumerate()
|
||
.map(|(index, tab)| {
|
||
(
|
||
tab.id,
|
||
Rect::new(
|
||
LEADING + index as f32 * tab_width - offset,
|
||
0.0,
|
||
tab_width,
|
||
HEIGHT,
|
||
),
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
fn items(start: Option<f32>, tabs: &[Tab], width: f32) -> Vec<Item<'_>> {
|
||
let viewport = viewport(width);
|
||
tabs.iter()
|
||
.zip(tab_rects(start, tabs, width))
|
||
.filter(|(_, (_, rect))| {
|
||
rect.x + rect.width > viewport.x && rect.x < viewport.x + viewport.width
|
||
})
|
||
.map(|(tab, (_, rect))| Item { tab, rect })
|
||
.collect()
|
||
}
|
||
fn pill_rect(rect: Rect) -> Rect {
|
||
Rect::new(rect.x + 2.0, 4.0, (rect.width - 4.0).max(0.0), HEIGHT - 8.0)
|
||
}
|
||
fn close_rect(rect: Rect) -> Rect {
|
||
Rect::new(
|
||
rect.x + rect.width - 25.0,
|
||
(HEIGHT - 18.0) / 2.0,
|
||
18.0,
|
||
18.0,
|
||
)
|
||
}
|
||
|
||
pub fn hit(start: Option<f32>, tabs: &[Tab], width: f32, point: Point) -> Option<(u64, bool)> {
|
||
if point.x < LEADING
|
||
|| point.x >= width - NEW_TAB_SPACE
|
||
|| point.y < 0.0
|
||
|| point.y >= height(tabs)
|
||
{
|
||
return None;
|
||
}
|
||
items(start, tabs, width)
|
||
.into_iter()
|
||
.find(|item| pill_rect(item.rect).contains(point))
|
||
.map(|item| (item.tab.id, close_rect(item.rect).contains(point)))
|
||
}
|
||
|
||
/// Gap index in the complete published order, including overflow-hidden tabs.
|
||
pub fn insertion_index(
|
||
start: Option<f32>,
|
||
tabs: &[Tab],
|
||
width: f32,
|
||
point: Point,
|
||
) -> Option<usize> {
|
||
if point.x < LEADING || point.x >= width - NEW_TAB_SPACE || point.y < 0.0 || point.y >= HEIGHT {
|
||
return None;
|
||
}
|
||
if tabs.is_empty() {
|
||
return Some(0);
|
||
}
|
||
let visible: Vec<_> = items(start, tabs, width).into_iter().collect();
|
||
for item in &visible {
|
||
if point.x < item.rect.x + item.rect.width / 2.0 {
|
||
return tabs.iter().position(|tab| tab.id == item.tab.id);
|
||
}
|
||
}
|
||
visible
|
||
.last()
|
||
.and_then(|last| tabs.iter().position(|tab| tab.id == last.tab.id))
|
||
.map(|index| index + 1)
|
||
}
|
||
|
||
pub fn drag_anchor(
|
||
start: Option<f32>,
|
||
tabs: &[Tab],
|
||
width: f32,
|
||
id: u64,
|
||
point: Point,
|
||
) -> Option<Point> {
|
||
if !viewport(width).contains(point) {
|
||
return None;
|
||
}
|
||
let item = items(start, tabs, width)
|
||
.into_iter()
|
||
.find(|item| item.tab.id == id && pill_rect(item.rect).contains(point))?;
|
||
(!close_rect(item.rect).contains(point)).then(|| Point::new(point.x - item.rect.x, point.y))
|
||
}
|
||
|
||
pub fn draw_insertion(
|
||
start: Option<f32>,
|
||
list: &mut DisplayList,
|
||
tabs: &[Tab],
|
||
width: f32,
|
||
index: usize,
|
||
scale: f32,
|
||
) {
|
||
let visible: Vec<_> = items(start, tabs, width).into_iter().collect();
|
||
let x = if tabs.is_empty() {
|
||
Some(LEADING + 8.0)
|
||
} else {
|
||
visible
|
||
.iter()
|
||
.find(|item| tabs.get(index).is_some_and(|tab| tab.id == item.tab.id))
|
||
.map(|item| item.rect.x)
|
||
.or_else(|| {
|
||
visible
|
||
.last()
|
||
.filter(|item| {
|
||
tabs.iter().position(|tab| tab.id == item.tab.id) == index.checked_sub(1)
|
||
})
|
||
.map(|item| item.rect.x + item.rect.width)
|
||
})
|
||
};
|
||
if let Some(x) = x {
|
||
list.push(DrawCommand::FillRoundedRect {
|
||
rect: Rect::new(
|
||
x.clamp(1.5, (width - 1.5).max(1.5)) * scale - 1.5 * scale,
|
||
3.0 * scale,
|
||
3.0 * scale,
|
||
(HEIGHT - 6.0) * scale,
|
||
),
|
||
radius: scale,
|
||
color: Color::rgb(56, 95, 220),
|
||
});
|
||
}
|
||
}
|
||
|
||
pub fn plus_rect(start: Option<f32>, tabs: &[Tab], width: f32) -> Rect {
|
||
if tabs.len() as f32 * tab_width(tabs, width) > viewport(width).width {
|
||
return Rect::new(width - 34.0, (HEIGHT - 28.0) / 2.0, 24.0, 28.0);
|
||
}
|
||
let end = items(start, tabs, width)
|
||
.iter()
|
||
.map(|item| item.rect.x + item.rect.width)
|
||
.fold(LEADING, f32::max);
|
||
Rect::new(
|
||
(end + 4.0).min(width - 34.0),
|
||
(HEIGHT - 28.0) / 2.0,
|
||
24.0,
|
||
28.0,
|
||
)
|
||
}
|
||
|
||
fn tab_style() -> TextStyle {
|
||
TextStyle {
|
||
font_size: 13.0,
|
||
line_height: 18.0,
|
||
..TextStyle::ui(1)
|
||
}
|
||
}
|
||
|
||
pub fn draw(
|
||
_start: Option<f32>,
|
||
list: &mut DisplayList,
|
||
tabs: &[Tab],
|
||
width: f32,
|
||
scale: f32,
|
||
_pointer: Option<Point>,
|
||
visual: &Visual,
|
||
) {
|
||
let scaled = |rect: Rect| {
|
||
Rect::new(
|
||
rect.x * scale,
|
||
rect.y * scale,
|
||
rect.width * scale,
|
||
rect.height * scale,
|
||
)
|
||
};
|
||
list.push(DrawCommand::FillRoundedRect {
|
||
rect: scaled(Rect::new(0.0, 0.0, width, HEIGHT)),
|
||
radius: 0.0,
|
||
color: crate::system_ui::theme::BAR,
|
||
});
|
||
let plus = visual.plus;
|
||
if visual.plus_hover > 0.0 {
|
||
list.push(DrawCommand::FillRoundedRect {
|
||
rect: scaled(plus),
|
||
radius: 6.0 * scale,
|
||
color: crate::system_ui::theme::mix(
|
||
crate::system_ui::theme::BAR,
|
||
crate::system_ui::theme::TEXT,
|
||
0.06 * visual.plus_hover,
|
||
),
|
||
});
|
||
}
|
||
list.push(DrawCommand::Text {
|
||
origin: Point::new(
|
||
(plus.x + (plus.width - crate::text::measure("+", tab_style()).width) / 2.0) * scale,
|
||
((HEIGHT - tab_style().line_height) / 2.0) * scale,
|
||
),
|
||
value: "+".into(),
|
||
style: tab_style().scaled(scale),
|
||
color: crate::system_ui::theme::MUTED,
|
||
});
|
||
let mut paint_items: Vec<_> = tabs
|
||
.iter()
|
||
.map(|tab| Item {
|
||
tab,
|
||
rect: visual.rect(tab.id, Rect::default()),
|
||
})
|
||
.collect();
|
||
paint_items.extend(
|
||
visual
|
||
.exiting
|
||
.iter()
|
||
.map(|(tab, rect)| Item { tab, rect: *rect }),
|
||
);
|
||
for item in paint_items {
|
||
{
|
||
if item.rect.width <= 0.0
|
||
|| item.rect.x + item.rect.width <= visual.viewport.x
|
||
|| item.rect.x >= visual.viewport.x + visual.viewport.width
|
||
{
|
||
continue;
|
||
}
|
||
list.push(DrawCommand::PushClip(scaled(visual.viewport)));
|
||
list.push(DrawCommand::PushClip(scaled(pill_rect(item.rect))));
|
||
}
|
||
let pill = scaled(pill_rect(item.rect));
|
||
let active = item.tab.active;
|
||
let hover = visual.hover(item.tab.id);
|
||
let background = if active {
|
||
crate::system_ui::theme::BG
|
||
} else {
|
||
crate::system_ui::theme::mix(
|
||
crate::system_ui::theme::BAR,
|
||
crate::system_ui::theme::TEXT,
|
||
0.06 * hover,
|
||
)
|
||
};
|
||
if active || hover > 0.0 {
|
||
list.push(DrawCommand::FillRoundedRect {
|
||
rect: pill,
|
||
radius: 6.0 * scale,
|
||
color: background,
|
||
});
|
||
}
|
||
{
|
||
// Match the close button with an 18px slot and 7px edge inset.
|
||
let icon = "◇";
|
||
list.push(DrawCommand::Text {
|
||
origin: Point::new(
|
||
(item.rect.x
|
||
+ 7.0
|
||
+ (18.0 - crate::text::measure(icon, tab_style()).width) / 2.0)
|
||
* scale,
|
||
((HEIGHT - tab_style().line_height) / 2.0) * scale,
|
||
),
|
||
value: icon.into(),
|
||
style: tab_style().scaled(scale),
|
||
color: crate::system_ui::theme::MUTED,
|
||
});
|
||
}
|
||
let title = &item.tab.title;
|
||
let text_rect = Rect::new(
|
||
item.rect.x + 30.0,
|
||
(HEIGHT - tab_style().line_height) / 2.0,
|
||
(item.rect.width - 58.0).max(0.0),
|
||
tab_style().line_height,
|
||
);
|
||
list.push(DrawCommand::PushClip(scaled(text_rect)));
|
||
list.push(DrawCommand::Text {
|
||
origin: Point::new(
|
||
(item.rect.x + 30.0) * scale,
|
||
((HEIGHT - tab_style().line_height) / 2.0) * scale,
|
||
),
|
||
value: title.into(),
|
||
style: tab_style().scaled(scale),
|
||
color: if active {
|
||
crate::system_ui::theme::TEXT
|
||
} else {
|
||
crate::system_ui::theme::MUTED
|
||
},
|
||
});
|
||
list.push(DrawCommand::PopClip);
|
||
if item.rect.width >= 44.0 {
|
||
let close_hover = visual.close_hover(item.tab.id);
|
||
if close_hover > 0.0 {
|
||
list.push(DrawCommand::FillRoundedRect {
|
||
rect: scaled(close_rect(item.rect)),
|
||
radius: 5.0 * scale,
|
||
color: crate::system_ui::theme::mix(
|
||
background,
|
||
crate::system_ui::theme::mix(
|
||
crate::system_ui::theme::BAR,
|
||
crate::system_ui::theme::TEXT,
|
||
0.12,
|
||
),
|
||
close_hover,
|
||
),
|
||
});
|
||
}
|
||
list.push(DrawCommand::Text {
|
||
origin: Point::new(
|
||
(close_rect(item.rect).x
|
||
+ (close_rect(item.rect).width
|
||
- crate::text::measure("×", tab_style()).width)
|
||
/ 2.0)
|
||
* scale,
|
||
((HEIGHT - tab_style().line_height) / 2.0) * scale,
|
||
),
|
||
value: "×".into(),
|
||
style: tab_style().scaled(scale),
|
||
color: crate::system_ui::theme::MUTED,
|
||
});
|
||
}
|
||
{
|
||
list.push(DrawCommand::PopClip);
|
||
list.push(DrawCommand::PopClip);
|
||
}
|
||
}
|
||
// Quiet boundaries only between two idle tabs; the active/hovered pill owns its edges.
|
||
list.push(DrawCommand::PushClip(scaled(visual.viewport)));
|
||
for pair in tabs.windows(2) {
|
||
let left = visual.rect(pair[0].id, Rect::default());
|
||
let hover = visual.hover(pair[0].id).max(visual.hover(pair[1].id));
|
||
if !pair[0].active && !pair[1].active && hover < 1.0 {
|
||
list.push(DrawCommand::FillRoundedRect {
|
||
rect: scaled(Rect::new(
|
||
left.x + left.width - 0.5,
|
||
(HEIGHT - 12.0) / 2.0,
|
||
1.0,
|
||
12.0,
|
||
)),
|
||
radius: 0.5 * scale,
|
||
color: crate::system_ui::theme::mix(
|
||
crate::system_ui::theme::BAR,
|
||
crate::system_ui::theme::TEXT,
|
||
0.18 * (1.0 - hover),
|
||
),
|
||
});
|
||
}
|
||
}
|
||
list.push(DrawCommand::PopClip);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
#[test]
|
||
fn insertion_gaps_share_visible_tab_geometry_and_exclude_close_drags() {
|
||
let tabs: Vec<_> = (1..=4)
|
||
.map(|id| Tab {
|
||
id,
|
||
title: id.to_string(),
|
||
developer_tool: None,
|
||
active: id == 1,
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
insertion_index(
|
||
None,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||
Point::new(LEADING + 10.0, 18.0)
|
||
),
|
||
Some(0)
|
||
);
|
||
assert_eq!(
|
||
insertion_index(
|
||
None,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||
Point::new(LEADING + 215.0, 18.0)
|
||
),
|
||
Some(1)
|
||
);
|
||
assert_eq!(
|
||
insertion_index(
|
||
None,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||
Point::new(LEADING + 990.0, 18.0)
|
||
),
|
||
Some(4)
|
||
);
|
||
assert!(
|
||
drag_anchor(
|
||
None,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||
1,
|
||
Point::new(LEADING + 190.0, 18.0)
|
||
)
|
||
.is_none()
|
||
);
|
||
assert!(
|
||
drag_anchor(
|
||
None,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||
1,
|
||
Point::new(LEADING + 25.0, 18.0)
|
||
)
|
||
.is_some()
|
||
);
|
||
assert_eq!(
|
||
insertion_index(
|
||
None,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||
Point::new(LEADING + 20.0, HEIGHT)
|
||
),
|
||
None
|
||
);
|
||
assert!(
|
||
drag_anchor(
|
||
None,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 400.0,
|
||
3,
|
||
Point::new(LEADING + 390.0, 18.0)
|
||
)
|
||
.is_none()
|
||
);
|
||
assert_eq!(
|
||
insertion_index(
|
||
None,
|
||
&[],
|
||
LEADING + NEW_TAB_SPACE + 720.0,
|
||
Point::new(LEADING + 20.0, 18.0)
|
||
),
|
||
Some(0)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn overflow_keeps_active_page_visible_without_navigation_buttons() {
|
||
let tabs: Vec<_> = (1..=64)
|
||
.map(|id| Tab {
|
||
id,
|
||
title: format!("Page {id}"),
|
||
developer_tool: None,
|
||
active: id == 32,
|
||
})
|
||
.collect();
|
||
let visible = items(None, &tabs, LEADING + NEW_TAB_SPACE + 720.0);
|
||
assert!(visible.iter().any(|item| item.tab.active));
|
||
for item in &visible {
|
||
let point = Point::new((item.rect.x + 12.0).max(LEADING + 3.0), 18.0);
|
||
assert_eq!(
|
||
hit(None, &tabs, LEADING + NEW_TAB_SPACE + 720.0, point),
|
||
Some((item.tab.id, close_rect(item.rect).contains(point)))
|
||
);
|
||
}
|
||
let tab = visible.iter().find(|item| item.tab.active).unwrap();
|
||
assert_eq!(
|
||
hit(
|
||
None,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 720.0,
|
||
Point::new(tab.rect.x + tab.rect.width - 15.0, 18.0)
|
||
),
|
||
Some((32, true))
|
||
);
|
||
assert_eq!(
|
||
hit(
|
||
None,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 720.0,
|
||
Point::new(LEADING + 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(),
|
||
developer_tool: None,
|
||
active: id == 32,
|
||
})
|
||
.collect();
|
||
let width = LEADING + NEW_TAB_SPACE + 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), 55..64);
|
||
assert_eq!(
|
||
edge_direction(&tabs, width, start, Point::new(LEADING + 710.0, 18.0)),
|
||
0
|
||
);
|
||
assert_eq!(
|
||
insertion_index(start, &tabs, width, Point::new(LEADING + 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..9);
|
||
assert_eq!(
|
||
edge_direction(&tabs, width, start, Point::new(LEADING + 10.0, 18.0)),
|
||
0
|
||
);
|
||
assert_eq!(
|
||
insertion_index(start, &tabs, width, Point::new(LEADING + 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));
|
||
}
|
||
|
||
#[test]
|
||
fn scrolled_hit_and_gap_geometry_survive_resize_and_list_shrink() {
|
||
let tabs: Vec<_> = (1..=16)
|
||
.map(|id| Tab {
|
||
id,
|
||
title: id.to_string(),
|
||
developer_tool: None,
|
||
active: id == 1,
|
||
})
|
||
.collect();
|
||
let start = Some(4.0 * MIN_WIDTH);
|
||
assert_eq!(
|
||
hit(
|
||
start,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 720.0,
|
||
Point::new(LEADING + 40.0, 18.0)
|
||
),
|
||
Some((5, false))
|
||
);
|
||
assert_eq!(
|
||
insertion_index(
|
||
start,
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 720.0,
|
||
Point::new(LEADING + 40.0, 18.0)
|
||
),
|
||
Some(4)
|
||
);
|
||
assert_eq!(
|
||
visible_range(&tabs[..3], LEADING + NEW_TAB_SPACE + 720.0, start),
|
||
0..3
|
||
);
|
||
assert_eq!(
|
||
visible_range(&tabs, LEADING + NEW_TAB_SPACE + 1920.0, start),
|
||
0..16
|
||
);
|
||
assert_eq!(
|
||
edge_direction(
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 720.0,
|
||
start,
|
||
Point::new(LEADING + 710.0, HEIGHT)
|
||
),
|
||
0
|
||
);
|
||
assert_eq!(
|
||
edge_direction(
|
||
&tabs,
|
||
LEADING + NEW_TAB_SPACE + 720.0,
|
||
start,
|
||
Point::new(LEADING + 725.0, 18.0)
|
||
),
|
||
0
|
||
);
|
||
}
|
||
}
|