Repository navigation
Expand file tree
/
Copy pathview_list.h
More file actions
1499 lines (1255 loc) · 47.1 KB
/
Copy pathview_list.h
File metadata and controls
1499 lines (1255 loc) · 47.1 KB
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
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// This file is part of the Diffractor photo and video organizer
// Copyright 2026 Zac Walker
//
// This program is free software; you can redistribute it and / or modify it
// under the terms of the LGPL License either version 2.1 or later.
// License details are available at https://www.gnu.org/licenses/lgpl-2.1.html
// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY
// Purpose: The row list the task views share (Rename, Import, Sync, Convert/Metadata/Date, Tags).
// list_view lays out the column header and rows, whose columns fit their content and are resized by
// dragging the divider after them, and tracks a run's progress and results; view_command_status is
// the status a run reports through; view_controls_host pairs the list with its controls panel; and
// the CollisionPolicy control is defined once here for every operation that writes to a destination.
#pragma once
#include "model.h"
#include "ui_view.h"
#include "ui_controllers.h"
#include "ui_elements.h"
#include "ui_dialog.h"
class list_view;
class view_controls_host;
class view_command_status;
using view_controls_host_ptr = std::shared_ptr<view_controls_host>;
struct view_operation_result
{
std::string name;
item_status status = item_status::cancel;
};
// One named CollisionPolicy control (docs/design.md) shared by every destination-writing
// operation so the vocabulary, ordering and behavior are identical everywhere.
// Storage for the radio flags is owned by the callbacks held by the returned element.
// A prompt raised because collisions were already found offers only the choices that resolve one:
// Block run would repeat the Cancel button beside it, and Skip is not always expressible.
inline view_element_ptr create_collision_policy_control(const ui::frame_ptr& frame, collision_policy& value,
std::function<void()> changed,
const bool allow_auto_rename = true,
const bool allow_block_and_skip = true)
{
struct policy_flags
{
bool block_run = false;
bool skip = false;
bool replace = false;
bool auto_rename = false;
};
const auto flags = std::make_shared<policy_flags>();
// Auto-rename is not offered by every operation; fall back rather than leave nothing selected.
if (!allow_auto_rename && value == collision_policy::auto_rename) value = collision_policy::skip;
if (!allow_block_and_skip && (value == collision_policy::block_run || value == collision_policy::skip))
value = allow_auto_rename ? collision_policy::auto_rename : collision_policy::replace;
flags->block_run = value == collision_policy::block_run;
flags->skip = value == collision_policy::skip;
flags->replace = value == collision_policy::replace;
flags->auto_rename = value == collision_policy::auto_rename;
auto group = std::make_shared<ui::group_control>();
group->add(std::make_shared<text_element>(tt.collision_policy_label));
auto apply = [flags, &value, allow_block_and_skip, changed = std::move(changed)](const bool)
{
if (flags->skip) value = collision_policy::skip;
else if (flags->replace) value = collision_policy::replace;
else if (flags->auto_rename) value = collision_policy::auto_rename;
// Nothing selected can only mean Block run where it is offered; where it is not, the safe
// default stands rather than a policy the prompt never showed.
else value = allow_block_and_skip ? collision_policy::block_run : collision_policy::auto_rename;
if (changed) changed();
};
if (allow_block_and_skip)
{
group->add(std::make_shared<ui::check_control>(frame, tt.collision_block, flags->block_run, true, false, apply,
radio_group_collision));
group->add(std::make_shared<ui::check_control>(frame, tt.collision_skip, flags->skip, true, false, apply,
radio_group_collision));
}
if (allow_auto_rename)
{
group->add(
std::make_shared<ui::check_control>(frame, tt.collision_rename, flags->auto_rename, true, false, apply,
radio_group_collision));
}
group->add(std::make_shared<ui::check_control>(frame, tt.collision_replace, flags->replace, true, false, apply,
radio_group_collision));
return group;
}
class list_view : public view_base
{
protected:
using this_type = list_view;
view_state& _state;
view_host_ptr _host;
sizei _extent;
view_scroller _scroller;
bool _rows_clickable = false;
progress_state _progress;
bool _showing_results = false;
bool _refresh_requested = false;
size_t _processing_generation = 0;
std::shared_ptr<std::atomic_int> _processing_cancel;
// The status line the frame's status bar shows: what the plan would do while it is reviewed, what
// the run did after.
std::string _status;
static constexpr int max_col_count = 4;
int col_count = 4;
int coll_offset = 0;
int col_widths[max_col_count] = {0, 0, 0, 0};
recti _col_header_bounds[max_col_count];
bool _header_active = false;
bool _header_tracking = false;
int _header_active_num = 0;
// A divider resizes the column before it, as in a file browser's details view. A column dragged
// to a width keeps it, in unscaled units so a change of DPI does too, until the view is opened
// again; zero is a column that sizes to its content. The last column has no divider after it: it
// takes whatever width the others leave.
double _user_col_widths[max_col_count] = {0, 0, 0, 0};
// What each column's content asks for before any shrinking to fit, which is what a double-click
// on the divider after it fits the column to.
int _content_col_widths[max_col_count] = {0, 0, 0, 0};
// Where the divider after each column but the last can be grabbed: across the header band.
recti _col_divider_bounds[max_col_count - 1];
int _min_col_width = 0;
int _cx_col_avail = 0;
double _scale_factor = 1.0;
int _divider_active = -1;
// The column headers are painted over the top of the scroller, so the rows they cover are inside
// the scroller but not visible. Text metrics are only available during layout, so the header
// height is held here for the scroll that has to clear it.
int _band_height = 0;
int _band_margin = 0;
friend class ui::scroll_controller;
friend class ui::clickable_controller;
friend class header_controller;
friend class column_divider_controller;
public:
struct row_element final : std::enable_shared_from_this<row_element>, view_element
{
list_view& _view;
std::string _text[max_col_count];
icon_index _icons[max_col_count] = {};
core::color _bg_row;
core::color32 _text_color[max_col_count] = {0, 0, 0, 0};
int _order = 0;
// This row's index into whatever vector the run reports against - the plan for the batch views,
// the dense work list for the others - or -1 when the run never reports this row at all. The
// header sort reorders _rows while the run stays in plan order, so display position is not an
// identity and nothing may index the run by it.
int _work_index = -1;
row_element(list_view& view) noexcept
: view_element(view_element_style::can_invoke), _view(view)
{
}
void render(render::draw_context& dc, const pointi element_offset) const override
{
const auto bg_alpha = dc.colors.alpha * dc.colors.bg_alpha;
const auto logical_bounds = bounds.offset(element_offset);
if (dc.clip_bounds().intersects(logical_bounds))
{
dc.draw_rect(logical_bounds, _bg_row.a_min(bg_alpha));
dc.draw_rect(logical_bounds, _bg_color);
auto x = logical_bounds.left + _view.coll_offset;
for (int i = 0; i < _view.col_count; i++)
{
const auto text_color = core::color(_text_color[i] != 0 ? _text_color[i] : dc.colors.foreground,
dc.colors.alpha);
const std::string_view text = _text[i];
const recti bounds(x, logical_bounds.top, x + _view.col_widths[i], logical_bounds.bottom);
if (_icons[i] != icon_index::none)
{
xdraw_icon(dc, _icons[i], bounds, text_color, {});
}
else
{
dc.draw_text(text, bounds, render::font_face::dialog, render::text_style::single_line,
text_color, {});
}
x += _view.col_widths[i] + dc.padding2;
}
}
}
sizei measure(render::measure_context& mc, int width_limit) const override
{
const auto row_height = mc.text_line_height(render::font_face::dialog) + mc.padding2;
return {width_limit, row_height};
}
void dispatch_event(const view_element_event& event) override
{
}
view_controller_ptr controller_from_location(const view_host_ptr& host, const pointi loc,
const pointi element_offset,
hit_test_context& ctx) override
{
return default_controller_from_location(*this, host, loc, element_offset, ctx);
}
};
using row_element_ptr = std::shared_ptr<row_element>;
std::vector<row_element_ptr> _rows;
// The row currently being processed, held rather than indexed: sort() reorders _rows and a
// refresh replaces them, either of which would leave a stored position pointing at another row
// and strand the highlight on the one it left behind.
row_element_ptr _active_row;
list_view(view_state& state, view_host_ptr host) : _state(state), _host(std::move(host))
{
}
~list_view() override
{
_rows.clear();
}
progress_state progress() const override
{
return _progress;
}
std::string_view status() override
{
return _status;
}
// A completed run keeps its results through the file notifications its own writes raise. Refresh
// is the user asking to re-plan, so it is honoured even then; both arrive as the same invalidation,
// which is why the request is remembered here rather than read from the flag.
void reload() override
{
_refresh_requested = true;
}
void refresh_from_source() override
{
if (std::exchange(_refresh_requested, false) || !showing_results()) refresh();
}
void begin_processing(const size_t total)
{
// A run still going belongs to whatever this one replaces - an Analyze restarted by Refresh,
// say. Its worker reads the source it was given, so bumping that is what stops it; swapping
// in a new source alone let it scan on to an answer nobody would publish.
if (_processing_cancel) ++(*_processing_cancel);
++_processing_generation;
_processing_cancel = std::make_shared<std::atomic_int>();
_progress = {true, 0, static_cast<int64_t>(total)};
_active_row.reset();
_showing_results = false;
_state.invalidate_view(view_invalid::view_redraw | view_invalid::status | view_invalid::command_state |
view_invalid::app_layout);
}
size_t processing_generation() const { return _processing_generation; }
bool is_processing_generation(const size_t generation) const { return generation == _processing_generation; }
std::shared_ptr<std::atomic_int> processing_cancel_source() const { return _processing_cancel; }
// The status line a finished run leaves: what it did (the result counts), then why some rows did
// nothing (the run's own message) - a partial run needs both. A run that reports neither leaves
// the status the review showed.
static std::string conclude_run(std::string summary, std::string message, std::string review_status)
{
if (summary.empty()) return message.empty() ? std::move(review_status) : std::move(message);
if (message.empty()) return summary;
return summary + " " + message;
}
// The status a run reports through. Progress highlights the row being worked on; completion
// swaps the reviewed rows for the results and concludes the status line from them. Both are
// ignored once the view has moved past `generation`. The caller reads the generation and the
// cancel source straight after begin_processing: anything run in between can pump messages, and
// a view abandoned there must not have its next state mistaken for this run's. `hold` lives
// until the run reports back - the file handles a run detaches stay detached until then.
static std::shared_ptr<view_command_status> make_run_status(const std::shared_ptr<list_view>& view,
size_t generation,
std::shared_ptr<std::atomic_int> cancel_source,
std::string review_status,
std::shared_ptr<void> hold = {});
void processing_item(const size_t row_index, const size_t work_position)
{
if (!_progress.active || row_index >= _rows.size()) return;
if (_active_row) _active_row->_bg_color = {};
const auto& row = _rows[row_index];
_active_row = row;
_progress.position = static_cast<int64_t>(work_position);
row->_bg_color = core::color(ui::style::colors.important_background, 1.0f);
const auto client_height = _scroller.client_bounds().height();
auto reserve_top = _band_height + _band_margin;
// A view too short to hold the row below the headers has no range left to place it in, so it
// falls back to the whole client rather than refusing to move.
if (reserve_top + row->bounds.height() > client_height)
{
reserve_top = 0;
}
const auto visible_top = _scroller.scroll_offset().y + reserve_top;
const auto visible_bottom = _scroller.scroll_offset().y + client_height;
if (row->bounds.top < visible_top)
{
_scroller.scroll_offset(_host, 0, row->bounds.top - reserve_top);
}
else if (row->bounds.bottom > visible_bottom)
{
_scroller.scroll_offset(_host, 0, row->bounds.bottom - client_height);
}
_state.invalidate_view(view_invalid::view_redraw | view_invalid::status);
}
// Resolving a work index against the rows rather than indexing them: the header sort is offered
// throughout the review, so by the time the run reports progress the display order and the plan
// order need not agree. Takes a projection so the rows are scanned in place.
template <typename work_index_at>
static int row_for_work_index(const size_t row_count, const size_t work_index, work_index_at&& at)
{
if (work_index > static_cast<size_t>(std::numeric_limits<int>::max())) return -1;
const auto wanted = static_cast<int>(work_index);
// Every view builds its rows in work order, so until the user sorts, the row is where the
// index says. Without this the batch views scan the whole list once per item, and they queue
// a progress callback per item rather than coalescing - quadratic on a large selection.
if (work_index < row_count && at(work_index) == wanted) return static_cast<int>(work_index);
for (size_t row_index = 0; row_index < row_count; ++row_index)
{
if (at(row_index) == wanted) return static_cast<int>(row_index);
}
return -1;
}
void processing_work_item(const size_t work_index, const size_t work_position)
{
const auto row_index = row_for_work_index(_rows.size(), work_index,
[this](const size_t i) { return _rows[i]->_work_index; });
if (row_index < 0) return;
processing_item(static_cast<size_t>(row_index), work_position);
}
void processing_work_item(const size_t work_index)
{
processing_work_item(work_index, work_index + 1);
}
void end_processing()
{
if (_active_row) _active_row->_bg_color = {};
_active_row.reset();
_progress = {};
_state.invalidate_view(view_invalid::view_redraw | view_invalid::status | view_invalid::command_state |
view_invalid::app_layout);
}
// One sentence for a finished run, whatever view drew the rows: counts first, then the classes
// of row that did nothing, so a partial run is never read as a whole one.
static std::string format_operation_summary(const std::vector<view_operation_result>& results)
{
size_t succeeded = 0;
size_t failed = 0;
size_t skipped = 0;
size_t canceled = 0;
std::string first_succeeded;
std::string first_failed;
std::string first_skipped;
std::string first_canceled;
for (const auto& result : results)
{
switch (result.status)
{
case item_status::success:
if (first_succeeded.empty()) first_succeeded = result.name;
++succeeded;
break;
case item_status::fail:
if (first_failed.empty()) first_failed = result.name;
++failed;
break;
case item_status::ignore:
if (first_skipped.empty()) first_skipped = result.name;
++skipped;
break;
case item_status::cancel:
if (first_canceled.empty()) first_canceled = result.name;
++canceled;
break;
}
}
const auto total = static_cast<int64_t>(results.size());
std::string summary;
const auto append = [&summary](std::string text)
{
if (!summary.empty()) summary += " ";
summary += std::move(text);
};
if (succeeded > 0) append(format_plural_text(tt.processed_fmt, first_succeeded, succeeded, {}, total));
if (failed > 0) append(format_plural_text(tt.failed_items_fmt, first_failed, failed, {}, total));
if (skipped > 0) append(format_plural_text(tt.ignored_fmt, first_skipped, skipped, {}, total));
if (canceled > 0) append(format_plural_text(tt.canceled_items_fmt, first_canceled, canceled, {}, total));
return summary;
}
// True while the rows describe a finished run rather than a plan waiting to be run.
bool showing_results() const { return _showing_results; }
std::string show_results(const std::vector<view_operation_result>& results)
{
std::vector<row_element_ptr> rows;
rows.reserve(results.size());
for (const auto& result : results)
{
auto row = std::make_shared<row_element>(*this);
row->_text[1] = result.name;
row->_order = static_cast<int>(rows.size());
switch (result.status)
{
case item_status::success:
row->_icons[0] = icon_index::check;
break;
case item_status::fail:
row->_icons[0] = icon_index::error;
break;
case item_status::ignore:
row->_icons[0] = icon_index::none;
break;
case item_status::cancel:
row->_icons[0] = icon_index::cancel;
break;
}
rows.emplace_back(std::move(row));
}
auto summary = format_operation_summary(results);
_rows = std::move(rows);
_showing_results = true;
_state.invalidate_view(view_invalid::view_layout | view_invalid::controller | view_invalid::status |
view_invalid::command_state);
return summary;
}
bool cancel_processing()
{
if (!_progress.active) return false;
if (_processing_cancel) ++(*_processing_cancel);
return true;
}
// Everything queued before this point belongs to a view that is no longer on screen. Bumping the
// generation is what makes their completion callbacks no-ops: without it, an analysis still
// running when the user switched away published on its return and restored a runnable plan built
// from a selection the view had already let go of.
void abandon_processing()
{
++_processing_generation;
if (_processing_cancel) ++(*_processing_cancel);
_progress = {};
_active_row.reset();
}
void cancel_operation() override
{
cancel_processing();
}
// Close always exits. When a task is running, state what will stop and let the user choose.
// Returns true when the view should exit.
bool confirm_exit_while_processing(const std::string_view operation_title)
{
if (!_progress.active) return true;
const auto dlg = make_dlg(_host->owner());
const std::vector<view_element_ptr> controls = {
set_margin(std::make_shared<ui::title_control2>(dlg->_frame, icon_index::question,
tt.cancel_operation_title,
str_format(tt.cancel_operation_fmt.sv(), operation_title))),
std::make_shared<divider_element>(),
std::make_shared<ui::ok_cancel_control>(dlg->_frame, tt.button_cancel_operation, tt.button_keep_running)
};
if (dlg->show_modal(controls, {44}, {33}) != ui::close_result::ok) return false;
cancel_processing();
return true;
}
bool can_exit() override
{
return !_progress.active;
}
bool confirm_exit() override
{
return confirm_exit_while_processing(operation_name());
}
void activate(const sizei extent) override
{
_extent = extent;
_state.stop();
// Each use of the task starts from columns that fit what it lists.
std::ranges::fill(_user_col_widths, 0.0);
}
void sort(int col_num)
{
if (col_num == 1 || col_num == 2)
{
std::ranges::sort(_rows, [col_num](const auto& left, const auto& right)
{
return core::icmp(left->_text[col_num], right->_text[col_num]) < 0;
});
}
else
{
std::ranges::sort(_rows, [](const auto& left, const auto& right) { return left->_order < right->_order; });
}
_state.invalidate_view(view_invalid::controller | view_invalid::view_layout);
}
void layout(render::measure_context& mc, const sizei extent) override
{
_extent = extent;
const auto titles = col_titles();
const recti scroll_bounds{_extent.cx - mc.scroll_width, 0, _extent.cx, _extent.cy};
const recti client_bounds{0, 0, std::max(0, _extent.cx - mc.scroll_width), _extent.cy};
const auto line_height = mc.text_line_height(render::font_face::dialog);
_band_margin = line_height;
_band_height = line_height + mc.padding2 * 2;
auto y_max = 0;
std::string_view longest_text[max_col_count];
bool has_icon[max_col_count] = {};
bool odd_row = false;
for (int i = 0; i < col_count; i++)
{
longest_text[i] = titles[i].sv();
}
if (!_rows.empty())
{
const auto row_height = mc.text_line_height(render::font_face::dialog) + mc.padding2;
auto y = row_height + mc.padding2 * 2;
for (const auto& r : _rows)
{
r->bounds.set(client_bounds.left, y, client_bounds.right, y + row_height);
y += row_height + mc.padding1;
for (int i = 0; i < col_count; i++)
{
const std::string_view text = r->_text[i];
if (text.size() > longest_text[i].size()) longest_text[i] = text;
has_icon[i] = has_icon[i] || r->_icons[i] != icon_index::none;
}
r->_bg_row.a = odd_row ? 0.11f : 0.0f;
odd_row = !odd_row;
}
y_max = y + mc.padding2;
}
auto total = 0;
auto shrink_col_total = 0;
_scale_factor = mc.scale_factor;
_min_col_width = std::max(mc.icon_cxy + mc.padding2, core::round(32 * mc.scale_factor));
// The table starts a margin in from the view's left edge however little it holds - never centred in
// the view - so its text does not run up against the window's frame, and runs to the right edge half a
// gap in, with a gap between each column and the next.
coll_offset = mc.padding2 * 2;
_cx_col_avail = std::max(0, client_bounds.width() - coll_offset - (col_count - 1) * mc.padding2 -
mc.padding2 / 2);
for (int i = 0; i < col_count; i++)
{
auto content_width = mc.measure_text(longest_text[i], render::font_face::dialog,
render::text_style::single_line, client_bounds.width()).cx +
mc.padding2;
if (has_icon[i]) content_width = std::max(content_width, mc.icon_cxy + mc.padding2);
_content_col_widths[i] = content_width;
col_widths[i] = is_user_sized(i) ? core::round(_user_col_widths[i] * mc.scale_factor) : content_width;
total += col_widths[i];
if (i > 0 && !is_user_sized(i)) shrink_col_total += col_widths[i];
}
// Shrink if too large. The columns that size to their content give way, all but the first, which
// keeps its width. A column the user sized keeps its width while they can, and only when they
// cannot does every column give way in proportion.
if (total > _cx_col_avail)
{
const auto cx_avail = _cx_col_avail;
auto fixed_width = 0;
for (int i = 0; i < col_count; i++)
{
if (i == 0 || is_user_sized(i)) fixed_width += col_widths[i];
}
if (fixed_width < cx_avail && shrink_col_total > 0)
{
const auto shrink_avail = cx_avail - fixed_width;
for (int i = 1; i < col_count; i++)
{
if (!is_user_sized(i)) col_widths[i] = core::mul_div(shrink_avail, col_widths[i], shrink_col_total);
}
}
else
{
const auto unshrunk = total;
for (int i = 0; i < col_count; i++)
{
col_widths[i] = core::mul_div(cx_avail, col_widths[i], unshrunk);
}
}
}
// The last column takes whatever the others leave, so the table always reaches the view's right
// edge: a narrower column gives its room to the last, and a wider one takes it from there.
if (col_count > 0)
{
auto others = 0;
for (int i = 0; i < col_count - 1; i++) others += col_widths[i];
col_widths[col_count - 1] = std::max(0, _cx_col_avail - others);
}
// Each divider sits in the middle of the gap between two columns, a grip either side of it.
const auto grip = std::max(mc.padding2 / 2, core::round(4 * mc.scale_factor));
auto column_x = coll_offset;
for (int i = 0; i < max_col_count - 1; i++)
{
_col_divider_bounds[i] = {};
if (i >= col_count - 1) continue;
column_x += col_widths[i] + mc.padding2;
const auto divider_x = column_x - mc.padding2 / 2;
_col_divider_bounds[i] = recti(divider_x - grip, 0, divider_x + grip, _band_height);
}
_scroller.layout({client_bounds.width(), y_max}, client_bounds, scroll_bounds);
_host->frame()->invalidate();
}
bool is_user_sized(const int col) const
{
return _user_col_widths[col] > 0.0;
}
// How many of the titled columns the list lays out and draws.
int column_count() const
{
return col_count;
}
// What a resize can be taken back to: every column's width.
struct column_sizes
{
std::array<double, max_col_count> widths{};
};
column_sizes current_column_sizes() const
{
column_sizes result;
std::ranges::copy(_user_col_widths, result.widths.begin());
return result;
}
void restore_column_sizes(const column_sizes& sizes)
{
std::ranges::copy(sizes.widths, _user_col_widths);
_state.invalidate_view(view_invalid::view_layout);
}
// The widest a column can be: the room the columns have, less the columns before it and those
// after it the user sized, less the narrowest the others after it shrink to.
int max_col_width(const int col) const
{
auto taken = 0;
for (int i = 0; i < col_count; i++)
{
if (i == col) continue;
taken += i < col || is_user_sized(i) ? col_widths[i] : std::min(col_widths[i], _min_col_width);
}
return std::max(_min_col_width, _cx_col_avail - taken);
}
// Sizes one column, as a drag on the divider after it asks. Every column before this one is held as
// it is on screen, shrunk to fit or not, so the divider stays under the pointer, and this one goes no
// narrower than a column can and no wider than the columns after it leave room for. The last column
// takes up what it gives back, and makes the room it takes.
void resize_column(const int col, const int width)
{
if (col < 0 || col >= col_count - 1) return;
for (int i = 0; i < col; i++)
{
if (!is_user_sized(i)) _user_col_widths[i] = col_widths[i] / _scale_factor;
}
_user_col_widths[col] = std::clamp(width, _min_col_width, max_col_width(col)) / _scale_factor;
_state.invalidate_view(view_invalid::view_layout);
}
// Fits a column to its content, as far as the columns after it can make room.
void fit_column(const int col)
{
if (col >= 0 && col < col_count - 1) resize_column(col, _content_col_widths[col]);
}
void deactivate() override
{
}
view_controller_ptr controller_from_location(const view_host_ptr& host, pointi loc, hit_test_context& ctx) override;
view_element_ptr element_from_location(const int y) const
{
view_element_ptr result;
const auto offset = _scroller.scroll_offset();
int distance = 10000;
for (const auto& i : _rows)
{
const auto yy = (i->bounds.top + i->bounds.bottom) / 2;
const auto d = abs(yy - y - offset.y);
if (d < distance)
{
distance = d;
result = i;
}
}
return result;
}
void render_headers(render::draw_context& dc)
{
const auto titles = col_titles();
const auto cy = dc.text_line_height(render::font_face::dialog) + dc.padding2 * 2;
const auto bg_alpha = dc.colors.alpha * 0.77f;
const auto text_color = core::color(dc.colors.foreground, dc.colors.alpha);
constexpr int y = 0;
auto x = coll_offset;
const core::color header_bg(core::darken(dc.colors.background, 0.33f), bg_alpha);
if (!_header_active)
{
const auto header_bounds = recti(0, y, _extent.cx, y + cy);
dc.draw_rect(header_bounds, header_bg);
}
else
{
auto xx = x;
for (int i = 0; i < col_count; ++i)
{
xx += col_widths[i] + dc.padding2;
}
const auto header_bounds_left = recti(0, y, x - dc.padding2, y + cy);
const auto header_bounds_right = recti(xx - dc.padding2, y, _extent.cx, y + cy);
dc.draw_rect(header_bounds_left, header_bg);
dc.draw_rect(header_bounds_right, header_bg);
}
for (int i = 0; i < col_count; ++i)
{
const auto cx = col_widths[i];
const recti col_header_bounds(x - dc.padding2, y, x + cx, y + cy);
const recti col_header_text_bounds(x, y, x + cx, y + cy);
if (_header_active)
{
const core::color bg(core::darken(_header_active_num == i
? ui::style::colors.dialog_selected_background
: dc.colors.background, 0.33f), bg_alpha);
dc.draw_rect(col_header_bounds, bg);
}
dc.draw_text(titles[i], col_header_text_bounds, render::font_face::dialog,
render::text_style::single_line, text_color, {});
_col_header_bounds[i] = col_header_bounds;
x += cx + dc.padding2;
}
// The dividers are drawn so the columns read as resizable: faintly, and plainly under the pointer.
const auto line_width = std::max(1, core::round(dc.scale_factor));
for (int i = 0; i < col_count - 1; ++i)
{
const auto& divider = _col_divider_bounds[i];
if (divider.is_empty()) continue;
const auto active = _divider_active == i;
const auto width = active ? line_width * 2 : line_width;
const auto mid = (divider.left + divider.right) / 2;
const recti line(mid - width / 2, y + dc.padding1, mid - width / 2 + width, y + cy - dc.padding1);
dc.draw_rect(line, core::color(dc.colors.foreground, dc.colors.alpha * (active ? 0.9f : 0.3f)));
}
}
void render(render::draw_context& rc, view_controller_ptr controller) override
{
const auto offset = -_scroller.scroll_offset();
if (!_rows.empty())
{
for (const auto& i : _rows)
{
i->render(rc, offset);
}
render_headers(rc);
_scroller.draw_scroll(rc);
}
else
{
// An open-ended run has no rows to show yet, so the work area says what it is doing rather
// than standing blank or claiming to be empty; the status bar says it as well.
const auto running = _progress.active && _progress.total == 0;
const std::string_view message = running ? status() : empty_message().sv();
if (!message.empty())
{
const auto text_color = core::color(rc.colors.foreground, rc.colors.alpha);
rc.draw_text(message, recti(_extent), render::font_face::dialog,
render::text_style::single_line_center, text_color, {});
}
}
}
bool mouse_wheel(const pointi loc, const platform::wheel_notch notch) override
{
if (!notch.is_vertical()) return false;
_scroller.offset(_host, 0, -notch.delta);
_state.invalidate_view(view_invalid::controller);
return _scroller.can_scroll();
}
virtual text_t empty_message() { return {}; }
virtual std::array<text_t, max_col_count> col_titles()
{
return std::array<text_t, max_col_count>{
text_t{},
text_t{},
text_t{},
text_t{}
};
};
};
class view_command_status final : public df::status_i, public std::enable_shared_from_this<view_command_status>
{
async_strategy& _async;
std::shared_ptr<std::atomic_int> _cancel_source;
int _cancel_version = 0;
std::function<void(size_t)> _started;
std::function<void(std::string, std::vector<view_operation_result>)> _completed;
std::atomic_size_t _position = 0;
std::atomic_bool _has_failures = false;
std::atomic_bool _is_complete = false;
core::mutex _progress_mutex;
_Guarded_by_(_progress_mutex) size_t _pending_position = 0;
_Guarded_by_(_progress_mutex) bool _progress_callback_pending = false;
_Guarded_by_(_progress_mutex) std::vector<view_operation_result> _results;
public:
view_command_status(async_strategy& async, std::shared_ptr<std::atomic_int> cancel_source,
std::function<void(size_t)> started,
std::function<void(std::string, std::vector<view_operation_result>)> completed) :
_async(async), _cancel_source(std::move(cancel_source)),
_cancel_version(_cancel_source ? _cancel_source->load() : 0),
_started(std::move(started)), _completed(std::move(completed))
{
}
void start_item(std::string_view) override
{
const auto position = _position.fetch_add(1);
auto queue_callback = false;
{
core::exclusive_lock lock(_progress_mutex);
_pending_position = position;
if (!_progress_callback_pending)
{
_progress_callback_pending = true;
queue_callback = true;
}
}
if (queue_callback)
{
_async.queue_ui([self = shared_from_this()] { self->publish_progress(); });
}
}
void end_item(const std::string_view name, const item_status status) override
{
if (status == item_status::fail) _has_failures = true;
core::exclusive_lock lock(_progress_mutex);
_results.emplace_back(std::string(name), status);
}
bool has_failures() const override { return _has_failures; }
bool is_canceled() const override
{
return _cancel_source && _cancel_source->load() != _cancel_version;
}
void abort(const std::string_view message) override { finish(std::string(message)); }
void complete(const std::string_view message = {}) override { finish(std::string(message)); }
void show_errors() override { finish({}); }
void show_message(const std::string_view message) override { finish(std::string(message)); }
void message(std::string_view, int64_t, int64_t) override
{
}
void wait_for_complete() const override
{
}
private:
void publish_progress()
{
size_t position;
{
core::exclusive_lock lock(_progress_mutex);
position = _pending_position;
_progress_callback_pending = false;
}
if (_started) _started(position);
}
void finish(std::string message)
{
if (_is_complete.exchange(true)) return;
std::vector<view_operation_result> results;
{
core::exclusive_lock lock(_progress_mutex);