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