1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
//! A custom tab widget shared by the inner graph tree and the outer pane tree.
//!
//! It renders a tab as plain text (no background box) coloured by state, with a
//! small close button, so all tabs look consistent.
/// Response from the [`Tab`] widget.
pub struct TabResponse {
/// The response for the tab area (for click/drag detection).
pub tab: egui::Response,
/// The response for the close button, if present.
pub close: Option<egui::Response>,
}
/// A tab widget displaying a title with an optional close button.
pub struct Tab {
text: egui::WidgetText,
active: bool,
closable: bool,
id: egui::Id,
/// Optional hover hint for the tab (e.g. "double-click to rename").
hint: Option<egui::WidgetText>,
/// Optional painted status dot after the title: colour + hover text.
status: Option<(egui::Color32, egui::WidgetText)>,
}
impl Tab {
pub fn new(text: impl Into<egui::WidgetText>, id: egui::Id) -> Self {
Self {
text: text.into(),
active: false,
closable: false,
id,
hint: None,
status: None,
}
}
/// Show a small painted status dot after the title (e.g. a collab
/// session's connection state) with the given hover text. Painted, not a
/// glyph: circle glyphs are missing from egui's default fonts on some
/// platforms.
pub fn status_dot(mut self, color: egui::Color32, hover: impl Into<egui::WidgetText>) -> Self {
self.status = Some((color, hover.into()));
self
}
/// Set whether this tab is currently active (selected).
pub fn active(mut self, active: bool) -> Self {
self.active = active;
self
}
/// Set whether this tab has a close button.
pub fn closable(mut self, closable: bool) -> Self {
self.closable = closable;
self
}
/// Set a hover hint shown over the tab.
pub fn hint(mut self, hint: impl Into<egui::WidgetText>) -> Self {
self.hint = Some(hint.into());
self
}
/// Show the widget.
pub fn show(self, ui: &mut egui::Ui) -> TabResponse {
let Self {
text,
active,
closable,
id,
hint,
status,
} = self;
let font_id = egui::TextStyle::Button.resolve(ui.style());
let galley = text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, font_id);
let x_margin = ui.spacing().button_padding.x;
let close_btn_width = if closable {
// Width for the close button area.
ui.spacing().icon_width
} else {
0.0
};
let dot_width = if status.is_some() {
ui.spacing().icon_width * 0.75
} else {
0.0
};
let desired_size = egui::vec2(
galley.size().x + 2.0 * x_margin + dot_width + close_btn_width,
ui.available_height(),
);
let (rect, _) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
// Use ui.interact for proper drag support, like egui_tiles does.
let mut tab_response = ui
.interact(rect, id, egui::Sense::click_and_drag())
.on_hover_cursor(egui::CursorIcon::Grab);
if let Some(hint) = hint {
tab_response = tab_response.on_hover_text(hint);
}
let mut close_response = None;
if ui.is_rect_visible(rect) {
// Text color based on state - no background, only text responds.
let text_color = if active {
ui.visuals().strong_text_color()
} else if tab_response.hovered() {
ui.visuals().text_color()
} else {
ui.visuals().weak_text_color()
};
// Draw title text (leaving space for the dot/close areas).
let text_rect = rect
.shrink2(egui::vec2(x_margin, 0.0))
.with_max_x(rect.right() - close_btn_width - dot_width);
let text_pos = egui::Align2::LEFT_CENTER
.align_size_within_rect(galley.size(), text_rect)
.min;
ui.painter().galley(text_pos, galley, text_color);
// Draw the status dot between the title and the close button.
if let Some((color, hover)) = status {
let dot_rect = egui::Rect::from_min_max(
egui::pos2(rect.right() - close_btn_width - dot_width, rect.top()),
egui::pos2(rect.right() - close_btn_width, rect.bottom()),
);
ui.interact(dot_rect, id.with("status_dot"), egui::Sense::hover())
.on_hover_text(hover);
let radius = (dot_rect.width() * 0.3).min(dot_rect.height() * 0.5);
ui.painter().circle_filled(dot_rect.center(), radius, color);
}
// Draw close button if closable.
if closable {
let close_rect = egui::Rect::from_min_max(
egui::pos2(rect.right() - close_btn_width, rect.top()),
rect.right_bottom(),
);
let close_id = id.with("close");
let close_res = ui
.interact(close_rect, close_id, egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::Default);
// Draw the × character.
let close_color = if close_res.hovered() {
ui.visuals().strong_text_color()
} else {
ui.visuals().weak_text_color()
};
let close_font = egui::TextStyle::Body.resolve(ui.style());
ui.painter().text(
close_rect.center(),
egui::Align2::CENTER_CENTER,
"×",
close_font,
close_color,
);
close_response = Some(close_res);
}
}
TabResponse {
tab: tab_response,
close: close_response,
}
}
}