use iced::{
Color, Point, Rectangle, Renderer, Size, Theme,
mouse,
widget::canvas::{self, Action, Frame, Geometry, Path, Stroke},
widget::text,
};
use super::app::Message;
use super::tab::{Tab, TabGroup, GROUP_COLORS};
const NODE_R: f32 = 28.0; const EDGE_W: f32 = 1.5; const FONT_SIZE: f32 = 11.0;
#[derive(Debug, Clone, Default)]
pub struct SpatialState {
dragging: Option<(usize, Point)>, press_on_node: bool,
}
pub struct SpatialTabMap<'a> {
pub tabs: &'a [Tab],
pub groups: &'a [TabGroup],
pub node_positions: &'a [(f32, f32)], pub active: usize,
pub dark: bool,
}
impl canvas::Program<Message> for SpatialTabMap<'_> {
type State = SpatialState;
fn update(
&self,
state: &mut Self::State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
let cursor_pos = cursor.position_in(bounds)?;
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
for (i, pos) in self.node_positions.iter().enumerate() {
let dx = cursor_pos.x - pos.0;
let dy = cursor_pos.y - pos.1;
if (dx * dx + dy * dy).sqrt() <= NODE_R {
state.dragging = Some((i, cursor_pos));
state.press_on_node = true;
return Some(Action::request_redraw());
}
}
state.press_on_node = false;
None
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let was_dragging = state.dragging.is_some();
let on_node = state.press_on_node;
state.dragging = None;
state.press_on_node = false;
if on_node && !was_dragging {
for (i, pos) in self.node_positions.iter().enumerate() {
let dx = cursor_pos.x - pos.0;
let dy = cursor_pos.y - pos.1;
if (dx * dx + dy * dy).sqrt() <= NODE_R {
return Some(Action::publish(Message::SpatialTabSelected(i)));
}
}
} else if !on_node {
return Some(Action::publish(Message::ToggleSpatialMap));
}
Some(Action::request_redraw())
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
if let Some((idx, ref mut last)) = state.dragging {
let dx = cursor_pos.x - last.x;
let dy = cursor_pos.y - last.y;
if dx.abs() > 0.3 || dy.abs() > 0.3 {
*last = cursor_pos;
return Some(Action::publish(Message::SpatialMoveNode(idx, dx, dy)));
}
}
None
}
_ => None,
}
}
fn draw(
&self,
_state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<Geometry> {
let dark = self.dark;
let mut frame = Frame::new(renderer, bounds.size());
frame.fill_rectangle(
Point::ORIGIN,
bounds.size(),
Color::from_rgba(0.0, 0.0, 0.0, if dark { 0.72 } else { 0.55 }),
);
frame.fill_text(canvas::Text {
content: "Tab Map · click node to switch · drag to rearrange · click background to close".to_string(),
position: Point::new(bounds.width / 2.0, 14.0),
color: Color::from_rgba(1.0, 1.0, 1.0, 0.50),
size: iced::Pixels(11.0),
font: iced::Font::DEFAULT,
align_x: text::Alignment::Center,
align_y: iced::alignment::Vertical::Top,
line_height: text::LineHeight::default(),
shaping: text::Shaping::Basic,
max_width: bounds.width,
});
let n = self.node_positions.len().min(self.tabs.len());
for group in self.groups {
let members: Vec<usize> = (0..n)
.filter(|&i| self.tabs[i].group_id == Some(group.id))
.collect();
if members.len() < 2 {
continue;
}
let [r, g, b] = GROUP_COLORS[group.color_idx % GROUP_COLORS.len()];
let cx = members.iter().map(|&i| self.node_positions[i].0).sum::<f32>() / members.len() as f32;
let cy = members.iter().map(|&i| self.node_positions[i].1).sum::<f32>() / members.len() as f32;
let cluster_r = members.iter()
.map(|&i| {
let dx = self.node_positions[i].0 - cx;
let dy = self.node_positions[i].1 - cy;
(dx * dx + dy * dy).sqrt()
})
.fold(0.0f32, f32::max) + NODE_R + 12.0;
frame.fill(
&Path::circle(Point::new(cx, cy), cluster_r),
Color::from_rgba(r, g, b, 0.08),
);
frame.stroke(
&Path::circle(Point::new(cx, cy), cluster_r),
Stroke::default().with_color(Color::from_rgba(r, g, b, 0.20)).with_width(1.0),
);
}
for (i, tab) in self.tabs.iter().enumerate().take(n) {
if let Some(parent) = tab.opened_from {
if parent < n {
let (x1, y1) = self.node_positions[i];
let (x2, y2) = self.node_positions[parent];
frame.stroke(
&Path::line(Point::new(x1, y1), Point::new(x2, y2)),
Stroke::default()
.with_color(Color::from_rgba(0.6, 0.6, 0.8, 0.35))
.with_width(EDGE_W),
);
}
}
}
let cursor_pos = cursor.position_in(bounds);
for i in 0..n {
let (x, y) = self.node_positions[i];
let tab = &self.tabs[i];
let is_active = i == self.active;
let hovered = cursor_pos.map(|p| {
let dx = p.x - x;
let dy = p.y - y;
(dx * dx + dy * dy).sqrt() <= NODE_R
}).unwrap_or(false);
let (node_fill, node_stroke_col) = if let Some(gid) = tab.group_id {
if let Some(g) = self.groups.iter().find(|g| g.id == gid) {
let [r, gc, b] = GROUP_COLORS[g.color_idx % GROUP_COLORS.len()];
(
Color::from_rgba(r, gc, b, if is_active { 0.55 } else { 0.22 }),
Color::from_rgba(r, gc, b, 0.85),
)
} else {
default_node_colors(dark, is_active)
}
} else {
default_node_colors(dark, is_active)
};
let fill = if hovered && !is_active {
Color { a: (node_fill.a + 0.15).min(1.0), ..node_fill }
} else {
node_fill
};
frame.fill(&Path::circle(Point::new(x, y), NODE_R), fill);
frame.stroke(
&Path::circle(Point::new(x, y), NODE_R),
Stroke::default()
.with_color(node_stroke_col)
.with_width(if is_active { 2.5 } else { 1.0 }),
);
if tab.suspended {
frame.stroke(
&Path::circle(Point::new(x, y), NODE_R - 4.0),
Stroke::default()
.with_color(Color::from_rgba(0.9, 0.9, 1.0, 0.35))
.with_width(1.0),
);
}
let label = site_name_short(&tab.url);
frame.fill_text(canvas::Text {
content: label,
position: Point::new(x, y - FONT_SIZE / 2.0 + 1.0),
color: if is_active {
Color::WHITE
} else if dark {
Color::from_rgba(0.82, 0.82, 0.92, 1.0)
} else {
Color::from_rgba(0.12, 0.12, 0.22, 1.0)
},
size: iced::Pixels(FONT_SIZE),
font: iced::Font::DEFAULT,
align_x: text::Alignment::Center,
align_y: iced::alignment::Vertical::Top,
line_height: text::LineHeight::default(),
shaping: text::Shaping::Basic,
max_width: NODE_R * 2.0 - 4.0,
});
if is_active {
frame.fill(
&Path::circle(Point::new(x, y + NODE_R - 6.0), 3.5),
Color::from_rgba(0.3, 0.8, 1.0, 0.9),
);
}
}
vec![frame.into_geometry()]
}
fn mouse_interaction(
&self,
state: &Self::State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if state.dragging.is_some() {
return mouse::Interaction::Grabbing;
}
if let Some(p) = cursor.position_in(bounds) {
for pos in self.node_positions {
let dx = p.x - pos.0;
let dy = p.y - pos.1;
if (dx * dx + dy * dy).sqrt() <= NODE_R {
return mouse::Interaction::Pointer;
}
}
}
mouse::Interaction::default()
}
}
fn default_node_colors(dark: bool, active: bool) -> (Color, Color) {
if active {
(
Color::from_rgba(0.18, 0.35, 0.75, 0.80),
Color::from_rgba(0.45, 0.65, 1.00, 0.90),
)
} else if dark {
(
Color::from_rgba(0.15, 0.15, 0.22, 0.85),
Color::from_rgba(0.40, 0.40, 0.55, 0.60),
)
} else {
(
Color::from_rgba(0.88, 0.88, 0.95, 0.90),
Color::from_rgba(0.50, 0.50, 0.65, 0.70),
)
}
}
fn site_name_short(url: &str) -> String {
if url == "tkz:home" || url.is_empty() {
return "home".to_string();
}
let stripped = url
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("www.");
let host = stripped.split('/').next().unwrap_or(stripped);
let base = host.split('.').next().unwrap_or(host);
let s: String = base.chars().take(8).collect();
s
}