1use egui_extras::{Table, TableBuilder, TableRow};
2use fluent_zero::t;
3
4use super::{
5 error::TableError,
6 filter::{Filter, highlight::HighlightFilter, search::SearchBar},
7 state::TableState,
8};
9
10#[derive(Clone, Debug, Default)]
11pub struct ColumnState {
12 pub response: ColResponse,
13 pub sort_up: Option<bool>,
14}
15
16#[derive(Clone, Debug, Default)]
17pub struct ColResponse {
18 pub to_sort: bool,
19 pub hovered: bool,
20
21 pub filtering: Filter,
22 pub secondary_clicked: bool,
23}
24
25pub trait TableHeaderRowExt {
26 fn header_cell(
27 &mut self,
28 text: &str,
29 sort_up: &Option<bool>,
30 previous_response: &ColResponse,
31 org_colors: &[[u8; 3]],
32 user_colors: &[[u8; 3]],
33 ) -> Result<ColResponse, TableError>;
34}
35
36impl TableHeaderRowExt for TableRow<'_, '_> {
37 #[allow(clippy::too_many_lines)]
38 fn header_cell(
39 &mut self,
40 text: &str,
41 sort_up: &Option<bool>,
42 previous_response: &ColResponse,
43 org_colors: &[[u8; 3]],
44 user_colors: &[[u8; 3]],
45 ) -> Result<ColResponse, TableError> {
46 let mut response = ColResponse {
47 filtering: previous_response.filtering.clone(),
48 ..Default::default()
49 };
50 let mut halt_error: Option<TableError> = None;
51 let col_response = self
52 .col(|ui| {
53 let item_spacing = ui.spacing().item_spacing;
54 let gapless_rect = ui.max_rect().expand2(0.5 * item_spacing);
55 ui.painter().rect_filled(
56 gapless_rect,
57 egui::CornerRadius::ZERO,
58 ui.visuals().widgets.noninteractive.bg_stroke.color,
59 );
60
61 ui.horizontal(|ui| {
62 ui.strong(text);
63
64 if let Some(sort_up) = sort_up {
65 if *sort_up {
66 ui.strong("🔻");
67 } else {
68 ui.strong("🔺");
69 }
70 }
71
72 if previous_response.hovered && sort_up.is_none() {
73 ui.label("🔻");
74 }
75
76 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
77 ui.add_space(5.0);
78
79 let popup_id = ui.make_persistent_id(ui.id().with("filter"));
80 let popup_open = if previous_response.secondary_clicked {
81 egui::Popup::toggle_id(ui.ctx(), popup_id);
82 true
83 } else {
84 egui::Popup::is_id_open(ui.ctx(), popup_id)
85 };
86
87 if !popup_open {
89 if response.filtering.is_empty() {
90 response.filtering = Filter::default();
92 } else if response.filtering.search.text().is_empty()
93 && response.filtering.search.is_active()
94 {
95 response.filtering.search.clear();
97 }
98 }
99
100 if !response.filtering.is_empty() || previous_response.hovered || popup_open
102 {
103 let ellipsis_response = draw_vertical_ellipsis(ui);
104
105 let width = 150.0;
106 egui::Popup::menu(&ellipsis_response)
107 .id(popup_id)
108 .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
109 .show(|ui| {
110 ui.set_width(width);
111 set_menu_style(ui.style_mut());
112
113 ui.strong(t!("column-options"));
114 ui.separator();
115
116 SearchBar::new(&t!("filter-text"))
118 .ui(ui, &mut response.filtering.search);
119
120 if (!org_colors.is_empty() || !user_colors.is_empty())
122 && HighlightFilter::new(&t!("new-highlight-filter"))
123 .ui(
124 ui,
125 &mut response.filtering.highlight,
126 org_colors,
127 user_colors,
128 )
129 .is_err()
130 {
131 halt_error = Some(TableError::CorruptedState);
132 }
133
134 ui.separator();
139
140 let sort_desc = match sort_up {
142 Some(true) => format!(" {}", t!("current-ascending")),
143 Some(false) => format!(" {}", t!("current-descending")),
144 None => String::new(),
145 };
146 if ui
147 .button(format!("{} {sort_desc}", t!("toggle-sort")))
148 .clicked()
149 {
150 response.to_sort = true;
151 }
152 });
153 }
154 });
155 });
156 })
157 .1;
158
159 if let Some(err) = halt_error {
160 return Err(err);
161 }
162
163 if col_response.contains_pointer() {
164 response.hovered = true;
165 }
166 if col_response.clicked() {
167 response.to_sort = true;
168 }
169 if col_response.secondary_clicked() {
170 response.secondary_clicked = true;
171 }
172
173 Ok(response)
174 }
175}
176
177fn draw_vertical_ellipsis(ui: &mut egui::Ui) -> egui::Response {
178 let text_height = ui.text_style_height(&egui::TextStyle::Body) - 3.0;
180 let dot_radius = text_height * 0.13; let spacing = dot_radius * 2.0;
182 let ellipsis_height = (3.0 * dot_radius).mul_add(2.0, 2.0 * spacing); let (rect, response) = ui.allocate_exact_size(
186 egui::Vec2::new(dot_radius * 2.0, ellipsis_height),
187 egui::Sense::click(),
188 );
189
190 if ui.is_rect_visible(rect) {
192 let painter = ui.painter();
193 let color = if response.hovered() {
194 ui.visuals().widgets.hovered.bg_fill
195 } else {
196 ui.visuals().widgets.inactive.bg_fill
197 };
198
199 for i in 0..3 {
200 #[allow(clippy::cast_precision_loss)]
201 let center = rect.center()
202 + egui::Vec2::new(0.0, (i as f32 - 1.0) * dot_radius.mul_add(2.0, spacing));
203 painter.circle_filled(center, dot_radius, color);
204 }
205 }
206
207 response
208}
209
210pub trait HeaderTrait<'a> {
211 fn archived_headers(
212 self,
213 data: &TableState,
214 headers: impl IntoIterator<Item = &'a str>,
215 height: f32,
216 org_colors: &[[u8; 3]],
217 user_colors: &[[u8; 3]],
218 ) -> Result<(Vec<ColResponse>, Table<'a>), TableError>;
219}
220
221impl<'a> HeaderTrait<'a> for TableBuilder<'a> {
222 fn archived_headers(
223 self,
224 data: &TableState,
225 headers: impl IntoIterator<Item = &'a str>,
226 height: f32,
227 org_colors: &[[u8; 3]],
228 user_colors: &[[u8; 3]],
229 ) -> Result<(Vec<ColResponse>, Table<'a>), TableError> {
230 let headers = headers.into_iter();
231 let headers_count = headers.size_hint().0;
232 let mut messages = Vec::with_capacity(headers_count);
233
234 let default_response = ColResponse::default();
235 let mut halt_error: Option<TableError> = None;
236 let table = self.header(height, |mut header| {
237 for (i, title) in headers.enumerate() {
238 let (previous_response, sort_up) = data
240 .columns
241 .get(i)
242 .map_or((&default_response, None), |col| {
243 (&col.response, col.sort_up)
244 });
245
246 if let Ok(message) =
247 header.header_cell(title, &sort_up, previous_response, org_colors, user_colors)
248 {
249 messages.push(message);
250 } else {
251 halt_error = Some(TableError::CorruptedState);
252 return;
253 }
254 }
255 });
256
257 halt_error.map_or(Ok((messages, table)), Err)
258 }
259}
260
261pub const fn set_menu_style(style: &mut egui::Style) {
262 style.wrap_mode = Some(egui::TextWrapMode::Extend);
263
264 style.spacing.button_padding = egui::vec2(2.0, 0.0);
265 style.visuals.widgets.active.bg_stroke = egui::Stroke::NONE;
266 style.visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE;
267 style.visuals.widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT;
268 style.visuals.widgets.inactive.bg_stroke = egui::Stroke::NONE;
269
270 style.visuals.selection.bg_fill = egui::Color32::from_gray(50);
271 style.visuals.selection.stroke.color = egui::Color32::from_rgb(86, 92, 128);
272}