1use fluent_zero::t;
4
5use super::{
6 error::TableError,
7 filter::{Filter, highlight::HighlightFilter, search::SearchBar},
8};
9
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12pub enum HeaderMenuAnchor {
13 Cell,
15 #[default]
17 Cursor,
18}
19
20#[derive(Clone, Debug, Default)]
22pub struct ColumnState {
23 pub response: ColResponse,
24 pub sort_up: Option<bool>,
25}
26
27#[derive(Clone, Debug, Default)]
29pub struct ColResponse {
30 pub to_sort: bool,
31 pub hovered: bool,
32 pub filtering: Filter,
33 pub secondary_clicked: bool,
34}
35
36pub const fn set_menu_style(style: &mut egui::Style) {
38 style.wrap_mode = Some(egui::TextWrapMode::Extend);
39
40 style.spacing.button_padding = egui::vec2(2.0, 0.0);
41 style.visuals.widgets.active.bg_stroke = egui::Stroke::NONE;
42 style.visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE;
43 style.visuals.widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT;
44 style.visuals.widgets.inactive.bg_stroke = egui::Stroke::NONE;
45
46 style.visuals.selection.bg_fill = egui::Color32::from_gray(50);
47 style.visuals.selection.stroke.color = egui::Color32::from_rgb(86, 92, 128);
48}
49
50pub fn show_header_cell_contents(
52 ui: &mut egui::Ui,
53 text: &str,
54 sort_up: &Option<bool>,
55 previous_response: &ColResponse,
56 org_colors: &[[u8; 3]],
57 user_colors: &[[u8; 3]],
58 anchor_mode: HeaderMenuAnchor,
59) -> Result<ColResponse, TableError> {
60 let mut response = ColResponse {
61 filtering: previous_response.filtering.clone(),
62 ..Default::default()
63 };
64 let mut halt_error = None;
65 let is_hovered = ui.rect_contains_pointer(ui.max_rect());
66
67 let item_spacing = ui.spacing().item_spacing;
68 let gapless_rect = ui.max_rect().expand2(0.5 * item_spacing);
69
70 let bg_color = ui.visuals().widgets.noninteractive.weak_bg_fill;
72 ui.painter()
73 .rect_filled(gapless_rect, egui::CornerRadius::ZERO, bg_color);
74
75 let unique_id = ui.id();
78
79 let cell_interact = ui.interact(
81 gapless_rect,
82 unique_id.with("header_interact"),
83 egui::Sense::hover(),
84 );
85
86 let popup_id = ui.make_persistent_id(unique_id.with("filter"));
87 let anchor_pos_id = unique_id.with("popup_anchor_pos");
88
89 let mut click_anchor = ui.data_mut(|d| d.get_temp::<egui::Pos2>(anchor_pos_id));
91
92 let is_cell_hovered = ui.rect_contains_pointer(gapless_rect);
94 let right_clicked = is_cell_hovered && ui.input(|i| i.pointer.secondary_clicked());
95 if right_clicked {
96 egui::Popup::toggle_id(ui.ctx(), popup_id);
97 }
98
99 let popup_open = egui::Popup::is_id_open(ui.ctx(), popup_id);
100 let mut ellipsis_clicked = false;
101
102 let has_filter = !response.filtering.is_empty();
104 let is_sorted = sort_up.is_some();
105 let show_ellipsis = has_filter || is_hovered || popup_open;
106 let show_sort = is_sorted || is_hovered;
107
108 let right_padding = 4.0f32;
109 let icon_gap = 4.0f32;
110 let mut solid_width = 0.0f32;
111 let mut ellipsis_center_x = None;
112 let mut sort_center_x = None;
113
114 let cell_width = gapless_rect.width();
115 let right_x = gapless_rect.max.x;
116
117 if !ui.is_sizing_pass() && cell_width > 20.0 && (show_ellipsis || show_sort) {
118 let mut current_offset = right_padding;
119
120 if show_ellipsis {
121 ellipsis_center_x = Some(right_x - current_offset - 6.0); current_offset += 12.0;
123 }
124
125 if show_sort {
126 if show_ellipsis {
127 current_offset += icon_gap;
128 }
129 sort_center_x = Some(right_x - current_offset - 7.0); current_offset += 14.0;
131 }
132 solid_width = current_offset;
133 }
134
135 ui.horizontal(|ui| {
136 ui.add_space(8.0);
137
138 if ui.is_sizing_pass() {
139 ui.add(
140 egui::Label::new(egui::RichText::new(text).strong())
141 .selectable(false)
142 .wrap_mode(ui.wrap_mode()),
143 );
144
145 ui.add_space(8.0);
146 } else {
147 let max_text_width = cell_width - solid_width - 12.0; ui.allocate_ui(
150 egui::vec2(max_text_width.max(0.0), ui.available_height()),
151 |ui| {
152 ui.add(
153 egui::Label::new(egui::RichText::new(text).strong())
154 .selectable(false)
155 .wrap_mode(egui::TextWrapMode::Truncate),
156 );
157 },
158 );
159 }
160 });
161
162 if !ui.is_sizing_pass() && (show_ellipsis || show_sort) && cell_width > 20.0 {
163 let center_y = gapless_rect.center().y;
164
165 if let Some(cx) = ellipsis_center_x {
167 let ellipsis_rect =
168 egui::Rect::from_center_size(egui::pos2(cx, center_y), egui::vec2(12.0, 18.0));
169
170 let ellipsis_response = ui.interact(
171 ellipsis_rect,
172 unique_id.with("ellipsis_interact"),
173 egui::Sense::click(),
174 );
175
176 if ellipsis_response.clicked() {
177 ellipsis_clicked = true;
178 }
179
180 let center = ellipsis_rect.center();
182 let dot_radius = 1.3;
183 let spacing = 3.5;
184 let dot_color = if ellipsis_response.hovered() {
185 ui.visuals().widgets.hovered.text_color()
186 } else {
187 ui.visuals()
188 .widgets
189 .inactive
190 .text_color()
191 .linear_multiply(0.7)
192 };
193 for i in -1..=1 {
194 #[allow(clippy::cast_precision_loss)]
195 let dot_center = center + egui::vec2(0.0, (i as f32) * spacing);
196 ui.painter()
197 .circle_filled(dot_center, dot_radius, dot_color);
198 }
199 }
200
201 if let Some(cx) = sort_center_x {
203 let sort_rect =
204 egui::Rect::from_center_size(egui::pos2(cx, center_y), egui::vec2(14.0, 14.0));
205
206 let sort_text = match sort_up {
207 Some(false) => "🔺",
208 Some(true) | None => "🔻",
209 };
210
211 let sort_response = ui.interact(
212 sort_rect,
213 unique_id.with("sort_interact"),
214 egui::Sense::click(),
215 );
216 if sort_response.clicked() {
217 response.to_sort = true;
218 }
219
220 let font_id = egui::FontId::proportional(11.0);
221 ui.painter().text(
222 sort_rect.center(),
223 egui::Align2::CENTER_CENTER,
224 sort_text,
225 font_id,
226 if sort_up.is_some() {
227 ui.visuals().widgets.active.text_color()
228 } else {
229 ui.visuals()
230 .widgets
231 .inactive
232 .text_color()
233 .linear_multiply(0.4)
234 },
235 );
236 }
237 }
238
239 if ellipsis_clicked {
241 egui::Popup::toggle_id(ui.ctx(), popup_id);
242 }
243
244 if (right_clicked || ellipsis_clicked)
246 && click_anchor.is_none()
247 && let Some(pointer_pos) = ui.ctx().pointer_latest_pos()
248 {
249 ui.data_mut(|d| d.insert_temp(anchor_pos_id, pointer_pos));
250 click_anchor = Some(pointer_pos);
251 }
252
253 if is_cell_hovered && ui.input(|i| i.pointer.primary_clicked()) && !ellipsis_clicked {
255 response.to_sort = true;
256 }
257
258 let menu_anchor_response = match anchor_mode {
260 HeaderMenuAnchor::Cursor if let Some(pos) = click_anchor => {
261 let anchor_rect = egui::Rect::from_center_size(pos, egui::Vec2::splat(1.0));
262 ui.interact(
263 anchor_rect,
264 unique_id.with("dummy_popup_anchor"),
265 egui::Sense::hover(),
266 )
267 }
268 _ => cell_interact,
269 };
270
271 egui::Popup::menu(&menu_anchor_response)
273 .id(popup_id)
274 .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
275 .show(|ui| {
276 ui.set_width(150.0);
277 set_menu_style(ui.style_mut());
278
279 ui.strong(t!("column-options"));
280 ui.separator();
281
282 SearchBar::new(&t!("filter-text")).ui(ui, &mut response.filtering.search);
283
284 if (!org_colors.is_empty() || !user_colors.is_empty())
285 && HighlightFilter::new(&t!("new-highlight-filter"))
286 .ui(
287 ui,
288 &mut response.filtering.highlight,
289 org_colors,
290 user_colors,
291 )
292 .is_err()
293 {
294 halt_error = Some(TableError::CorruptedState);
295 }
296
297 ui.separator();
298
299 let sort_desc = match sort_up {
300 Some(true) => format!(" {}", t!("current-ascending")),
301 Some(false) => format!(" {}", t!("current-descending")),
302 None => String::new(),
303 };
304 if ui
305 .button(format!("{} {sort_desc}", t!("toggle-sort")))
306 .clicked()
307 {
308 response.to_sort = true;
309 ui.close_kind(egui::UiKind::Menu);
310 }
311 });
312
313 if !popup_open {
315 ui.data_mut(|d| {
316 d.remove::<egui::Pos2>(anchor_pos_id);
317 });
318 }
319
320 if let Some(err) = halt_error {
321 return Err(err);
322 }
323
324 if is_hovered {
325 response.hovered = true;
326 }
327 if ellipsis_clicked {
328 response.secondary_clicked = true;
329 }
330
331 Ok(response)
332}