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