rust_widgets 2.9.3

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! The order book ladder — asks above, bids below, best prices at the middle.
//!
//! # What a reader does with it
//!
//! Reads toward the centre rule. By default the asks stack above the rule with the
//! lowest (best) ask in the bottom ask row, and the bids stack below it with the
//! highest (best) bid in the top bid row; [`OrderBookWidget::set_side_order`] mirrors
//! that for terminals that stack bids on top. The two rows adjacent to the rule are the spread; the size
//! sitting at each level says how much would have to trade to move the price past it.
//! A ladder is therefore read row by row, which is why the levels are drawn in a fixed
//! row order rather than scaled to their prices: the interesting comparison is between
//! adjacent rows, and a price-proportional layout compresses exactly where the rows are
//! interesting.
//!
//! # Why the size bars are anchored to the outer edge
//!
//! Bids grow leftward from the centre spine and asks grow rightward. That way the
//! visual length of a bar is its size, both sides share one scale, and the spine itself
//! marks the price of the best bid and offer. Anchoring both to the left instead would
//! make the two sides look like one series.

use alloc::vec::Vec;
use core::fmt::Write as _;

use crate::core::{Color, HorizontalAlignment, Point, Rect};
use crate::render::RenderContext;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::role_font;
use crate::widget::special_widgets::finance::layout::PlotArea;
use crate::widget::special_widgets::finance::types::{BookLevel, OrderBook};
use crate::widget::special_widgets::finance::volume_chart::draw_empty_pane;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};

/// Which ladder a level belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BookSide {
    /// The bid ladder, highest price first.
    Bid,
    /// The ask ladder, lowest price first.
    Ask,
}

/// Which ladder is drawn above the spread rule.
///
/// Venues differ on whether the sell side sits above the buy side, so the order is
/// a display choice rather than part of the data model. The default keeps asks above
/// bids, which is the layout the widget has always drawn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BookSideOrder {
    /// Asks above the rule, bids below it.
    #[default]
    AsksAbove,
    /// Bids above the rule, asks below it.
    BidsAbove,
}

/// How many levels of each side the ladder shows.
///
/// Five is the depth Chinese and most European venues publish; ten is the common
/// Western default. The default here is five because it is the tighter reading and a
/// caller wanting more asks for it explicitly.
pub const DEFAULT_BOOK_DEPTH: usize = 5;

/// The order book control.
pub struct OrderBookWidget {
    base: BaseWidget,
    book: OrderBook,
    depth: usize,
    /// How many price decimals to draw. `None` infers them from the book.
    decimals: Option<usize>,
    bid_color: Color,
    ask_color: Color,
    hovered: Option<(BookSide, usize)>,
    /// Remaining flash alpha per level index, one entry per side. Zero means "no flash".
    ///
    /// An indexed collection rather than a list of `(side, index, alpha)` rows: `draw` asks
    /// "does this row flash?" once per level, and searching a list made that a linear scan
    /// per row. An index is O(1) and still carries the per-row alpha `tick` fades.
    flash_bid: Vec<u8>,
    flash_ask: Vec<u8>,
    /// Running quantity total from the touch outward, one entry per displayed level of each side.
    ///
    /// Rebuilt when the book or the depth changes rather than once per frame; the ladder is
    /// redrawn far more often than it is fed.
    cumulative_bid: Vec<f64>,
    cumulative_ask: Vec<f64>,
    /// The largest cumulative total across both sides, used to scale cumulative bars.
    cumulative_max: f64,
    /// Whether size bars show cumulative depth rather than the level's own quantity.
    cumulative_depth: bool,
    /// Which ladder is drawn above the spread rule.
    side_order: BookSideOrder,
    /// How many levels the visible window is scrolled past, from the touch outward.
    ///
    /// A book deeper than the pane used to be unreachable: `set_depth` truncated it and the
    /// rest was simply not drawn. The offset is shared by both ladders so they stay aligned
    /// across the spread rule (FIN-06).
    scroll_offset: usize,
    /// Bumped whenever the book or the visible depth changes.
    data_generation: u64,
    /// `(generation, decimals, max level quantity)` inferred for the current book.
    ///
    /// Both are pure functions of the visible levels and both are read on every frame. The
    /// generation is what makes the cache a cache and not a stale guess (J-P15).
    facts_cache: core::cell::Cell<Option<(u64, usize, f64)>>,
    /// Emitted with the side and the level index when a row is clicked.
    pub level_clicked: crate::signal::Signal1<(BookSide, usize)>,
    /// Emitted as the pointer moves onto a row.
    pub level_hovered: crate::signal::Signal1<(BookSide, usize)>,
    /// Emitted with the pointer leaves a row.
    pub level_unhovered: crate::signal::Signal1<(BookSide, usize)>,
}

impl OrderBookWidget {
    /// Creates an empty ladder.
    pub fn new(geometry: Rect) -> Self {
        Self {
            base: BaseWidget::new(WidgetKind::OrderBook, geometry, "OrderBook"),
            book: OrderBook::new(),
            depth: DEFAULT_BOOK_DEPTH,
            decimals: None,
            bid_color: Color::rgb(38, 166, 91),
            ask_color: Color::rgb(220, 68, 70),
            hovered: None,
            flash_bid: Vec::new(),
            flash_ask: Vec::new(),
            cumulative_bid: Vec::new(),
            cumulative_ask: Vec::new(),
            cumulative_max: 0.0,
            cumulative_depth: false,
            side_order: BookSideOrder::default(),
            scroll_offset: 0,
            data_generation: 0,
            facts_cache: core::cell::Cell::new(None),
            level_clicked: crate::signal::Signal1::new(),
            level_hovered: crate::signal::Signal1::new(),
            level_unhovered: crate::signal::Signal1::new(),
        }
    }

    /// The book being displayed.
    pub fn book(&self) -> &OrderBook {
        &self.book
    }

    /// Replaces the book and requests a repaint.
    ///
    /// The hovered row is re-validated rather than kept: a level index that existed in
    /// the previous book may be past the end of the new one, and a hover highlight on a
    /// row that is not there would point at nothing.
    pub fn set_book(&mut self, book: OrderBook) {
        // Rows whose size changed flash, so an update is visible without the caller diffing
        // two books itself. The comparison is per side and index against the whole side, not
        // the visible window: the window moves when the ladder is scrolled, and a flash must
        // stay attached to the level it describes.
        self.flash_bid.clear();
        self.flash_ask.clear();
        for side in [BookSide::Bid, BookSide::Ask] {
            let before = match side {
                BookSide::Bid => self.book.bids(),
                BookSide::Ask => self.book.asks(),
            };
            let after = match side {
                BookSide::Bid => book.bids(),
                BookSide::Ask => book.asks(),
            };
            let flashes = match side {
                BookSide::Bid => &mut self.flash_bid,
                BookSide::Ask => &mut self.flash_ask,
            };
            flashes.resize(after.len(), 0);
            for index in 0..after.len().min(before.len()) {
                if (before[index].quantity - after[index].quantity).abs() > f64::EPSILON {
                    flashes[index] = 255;
                }
            }
        }
        self.book = book;
        self.data_generation = self.data_generation.wrapping_add(1);
        self.clamp_scroll();
        self.rebuild_cumulative();
        if let Some((side, index)) = self.hovered {
            let count = match side {
                BookSide::Bid => self.book.bids().len(),
                BookSide::Ask => self.book.asks().len(),
            };
            if index >= count {
                self.hovered = None;
            }
        }
        self.base.request_redraw();
    }

    /// Which ladder is drawn above the spread rule.
    pub fn side_order(&self) -> BookSideOrder {
        self.side_order
    }

    /// Sets which ladder is drawn above the spread rule.
    ///
    /// The best price of each block always sits against the rule, so swapping the blocks
    /// only changes which side a reader looks up for bids; it does not reorder either
    /// ladder's own levels.
    pub fn set_side_order(&mut self, order: BookSideOrder) {
        if self.side_order == order {
            return;
        }
        self.side_order = order;
        self.base.request_redraw();
    }

    /// How many levels per side are shown.
    pub fn depth(&self) -> usize {
        self.depth
    }

    /// Makes the size bars show cumulative depth rather than each level's own quantity.
    pub fn set_cumulative_depth(&mut self, cumulative: bool) {
        if self.cumulative_depth == cumulative {
            return;
        }
        self.cumulative_depth = cumulative;
        self.base.request_redraw();
    }

    /// Returns whether size bars show cumulative depth.
    pub fn cumulative_depth(&self) -> bool {
        self.cumulative_depth
    }

    /// Returns whether any row is still flashing.
    pub fn is_flashing(&self) -> bool {
        self.flash_bid.iter().any(|alpha| *alpha > 0)
            || self.flash_ask.iter().any(|alpha| *alpha > 0)
    }

    /// Sets how many levels per side to show.
    ///
    /// A stored `0` would mean a ladder that draws nothing, so it is treated as a request
    /// to show nothing and is honoured literally — the caller may genuinely want the pane
    /// blank while it waits for the first snapshot.
    pub fn set_depth(&mut self, depth: usize) {
        if self.depth == depth {
            return;
        }
        self.depth = depth;
        self.data_generation = self.data_generation.wrapping_add(1);
        self.clamp_scroll();
        self.rebuild_cumulative();
        if let Some((side, index)) = self.hovered {
            let count = match side {
                BookSide::Bid => self.book.bids().len(),
                BookSide::Ask => self.book.asks().len(),
            };
            if index >= count {
                self.hovered = None;
            }
        }
        self.base.request_redraw();
    }

    /// The first visible level's absolute index.
    pub fn scroll_offset(&self) -> usize {
        self.scroll_offset
    }

    /// Scrolls the ladder to start at `offset` levels below the touch.
    ///
    /// The offset is shared by both sides and clamped to the deeper side's range, so the
    /// two ladders always start at the same depth step and the spread rule stays aligned
    /// (FIN-06).
    pub fn set_scroll_offset(&mut self, offset: usize) {
        let deepest = self.book.bids().len().max(self.book.asks().len());
        let max_offset = deepest.saturating_sub(self.depth);
        let offset = offset.min(max_offset);
        if offset == self.scroll_offset {
            return;
        }
        self.scroll_offset = offset;
        // The inferred precision reads the visible levels, so a new window is a new answer.
        self.data_generation = self.data_generation.wrapping_add(1);
        self.rebuild_cumulative();
        // A row that scrolled out of the window keeps its absolute index, but the hovered
        // marker should follow what is on screen; drop it rather than highlight a hidden row.
        self.hovered = None;
        self.base.request_redraw();
    }

    /// Scrolls by `delta` levels.
    pub fn scroll_by(&mut self, delta: isize) {
        let next = (self.scroll_offset as isize + delta).max(0) as usize;
        self.set_scroll_offset(next);
    }

    fn clamp_scroll(&mut self) {
        let deepest = self.book.bids().len().max(self.book.asks().len());
        self.scroll_offset = self.scroll_offset.min(deepest.saturating_sub(self.depth));
    }

    /// The explicit price precision, if one was set.
    pub fn decimals(&self) -> Option<usize> {
        self.decimals
    }

    /// Sets the price precision, or `None` to infer it from the book.
    ///
    /// Capped at six places, past which the column is wider than the price is meaningful.
    pub fn set_decimals(&mut self, decimals: Option<usize>) {
        let decimals = decimals.map(|value| value.min(6));
        if self.decimals == decimals {
            return;
        }
        self.decimals = decimals;
        self.base.request_redraw();
    }

    /// The row colours.
    pub fn colors(&self) -> (Color, Color) {
        (self.bid_color, self.ask_color)
    }

    /// Sets the bid and ask row colours.
    pub fn set_colors(&mut self, bid: Color, ask: Color) {
        self.bid_color = bid;
        self.ask_color = ask;
        self.base.request_redraw();
    }

    /// The row under the pointer.
    pub fn hovered(&self) -> Option<(BookSide, usize)> {
        self.hovered
    }

    /// The hovered row's readings, ready for a `ChartTooltip`.
    ///
    /// Price, resting quantity and the cumulative depth at that level, taken from the same
    /// cached arrays the bars are drawn from (COMP-J02 / COMP-J05).
    pub fn hovered_summary(&self) -> Vec<crate::widget::companions::TooltipLine> {
        use crate::widget::companions::TooltipLine;
        let Some((side, index)) = self.hovered else { return Vec::new() };
        let (window_start, levels) = self.level_window(side);
        let Some(level) = index.checked_sub(window_start).and_then(|local| levels.get(local))
        else {
            return Vec::new();
        };
        let cumulative = match side {
            BookSide::Bid => self.cumulative_bid.get(index).copied(),
            BookSide::Ask => self.cumulative_ask.get(index).copied(),
        }
        .unwrap_or(0.0);
        let decimals = self.effective_decimals();
        let mut price = alloc::string::String::new();
        write_price(level.price, decimals, &mut price);
        let color = if side == BookSide::Bid { self.bid_color } else { self.ask_color };
        alloc::vec![
            TooltipLine::colored(
                match side {
                    BookSide::Bid => "bid",
                    BookSide::Ask => "ask",
                }
                .to_string(),
                color,
            ),
            TooltipLine::new(price),
            TooltipLine::new(alloc::format!("qty {:.0}", level.quantity)),
            TooltipLine::new(alloc::format!("cum {cumulative:.0}")),
        ]
    }

    /// The tooltip text for one absolute level index, for region-based hover.
    ///
    /// `TooltipManager` registers text by a key; a ladder row has no widget id, so the
    /// control supplies the text for `(side, index)` and the host maps its own region id to
    /// that pair (COMP-J05).
    pub fn region_text(&self, side: BookSide, index: usize) -> Option<alloc::string::String> {
        let levels = match side {
            BookSide::Bid => self.book.bids(),
            BookSide::Ask => self.book.asks(),
        };
        let level = levels.get(index)?;
        let cumulative = match side {
            BookSide::Bid => self.cumulative_bid.get(index).copied(),
            BookSide::Ask => self.cumulative_ask.get(index).copied(),
        }
        .unwrap_or(0.0);
        let decimals = self.effective_decimals();
        let mut price = alloc::string::String::new();
        write_price(level.price, decimals, &mut price);
        Some(alloc::format!("{price} × {:.0} × {cumulative:.0}", level.quantity))
    }

    /// The levels shown on one side, best first, capped at the depth.
    pub fn levels(&self, side: BookSide) -> Vec<BookLevel> {
        self.level_slice(side).to_vec()
    }

    /// A borrowed view of the levels drawn for `side`.
    ///
    /// The draw and hit-test paths only read the levels, so they must not pay for the `Vec`
    /// `levels()` builds. `row_at` called `levels()` four times per pointer move and `draw`
    /// five to six times per frame, copying the same handful of rows each time.
    fn level_slice(&self, side: BookSide) -> &[BookLevel] {
        self.level_window(side).1
    }

    /// The visible window of one side: the absolute index of its first row and the slice.
    ///
    /// Every index the control reports — hover, click, flash and cumulative — is an
    /// **absolute** index into the book side, so scrolling cannot make the same row answer to
    /// two different numbers (FIN-06).
    fn level_window(&self, side: BookSide) -> (usize, &[BookLevel]) {
        let levels = match side {
            BookSide::Bid => self.book.bids(),
            BookSide::Ask => self.book.asks(),
        };
        let max_start = levels.len().saturating_sub(self.depth);
        let start = self.scroll_offset.min(max_start);
        let end = (start + self.depth).min(levels.len());
        (start, &levels[start..end])
    }

    /// Recomputes the running depth totals the cumulative bars are scaled against.
    ///
    /// Called when the book or the visible depth changes. `draw` used to build a `BTreeMap`
    /// over every level on every frame just to look totals up by index; the totals are a
    /// prefix sum of the displayed levels, so two arrays hold them exactly.
    fn rebuild_cumulative(&mut self) {
        let window_end_bid = (self.scroll_offset + self.depth).min(self.book.bids().len());
        let window_end_ask = (self.scroll_offset + self.depth).min(self.book.asks().len());
        Self::fill_cumulative(&mut self.cumulative_bid, self.book.bids(), window_end_bid);
        Self::fill_cumulative(&mut self.cumulative_ask, self.book.asks(), window_end_ask);
        // The scale is the cumulative total at the deepest *visible* level, which is the last
        // entry of each prefix array. Only that level fills its bar (FIN-06).
        let peak = |totals: &[f64]| totals.last().copied().unwrap_or(0.0).max(0.0);
        self.cumulative_max = peak(&self.cumulative_bid).max(peak(&self.cumulative_ask));
    }

    /// Prefix sums the first `count` levels of `levels` into `out`.
    ///
    /// The arrays are indexed by the **absolute** level index, so a scrolled window reads the
    /// same numbers a full ladder would and the first visible row shows the depth already
    /// resting above it (FIN-06).
    fn fill_cumulative(out: &mut Vec<f64>, levels: &[BookLevel], count: usize) {
        out.clear();
        out.reserve(count);
        let mut running = 0.0f64;
        for level in levels.iter().take(count) {
            if level.quantity.is_finite() && level.quantity > 0.0 {
                running += level.quantity;
            }
            out.push(running);
        }
    }

    /// The precision to draw prices at.
    fn effective_decimals(&self) -> usize {
        self.visible_facts().0
    }

    /// The price precision and the largest visible quantity for the current book.
    ///
    /// Both are pure functions of data that only changes when the book (or the visible
    /// depth) does, and both are read once per frame. The previous code inferred the
    /// decimals from every level and scanned every level for the maximum on each repaint,
    /// so a still ladder paid two full scans per frame (J-P15).
    fn visible_facts(&self) -> (usize, f64) {
        if let Some((generation, decimals, max)) = self.facts_cache.get() {
            if generation == self.data_generation {
                return (decimals, max);
            }
        }
        let decimals = self.decimals.unwrap_or_else(|| self.infer_decimals());
        let max = self.book.max_level_quantity();
        self.facts_cache.set(Some((self.data_generation, decimals, max)));
        (decimals, max)
    }

    /// Infers the precision from the visible levels.
    fn infer_decimals(&self) -> usize {
        // Infer from the levels, the same way the K-line pane infers from its bars: a
        // feed's precision is a property of the data, not a setting.
        for side in [BookSide::Bid, BookSide::Ask] {
            for level in self.level_slice(side) {
                if !level.price.is_finite() {
                    continue;
                }
                for decimals in 0..=6usize {
                    let scaled = level.price * 10f64.powi(decimals as i32);
                    if (scaled - scaled.round()).abs() < 1e-6 {
                        return decimals;
                    }
                }
            }
        }
        2
    }

    /// The height of one row.
    fn row_height(&self) -> i32 {
        let height = self.base.geometry().height as i32;
        // Both sides share the available height, so the two ladders stay aligned and the
        // pane does not scroll: a depth the pane cannot fit is clipped rather than
        // resized, because a changing row height between updates makes the ladder
        // unreadable.
        let rows = (self.depth * 2).max(1) as i32;
        (height / rows).max(1)
    }

    /// The ladder drawn above the rule.
    fn first_side(&self) -> BookSide {
        match self.side_order {
            BookSideOrder::AsksAbove => BookSide::Ask,
            BookSideOrder::BidsAbove => BookSide::Bid,
        }
    }

    /// The ladder drawn below the rule.
    fn second_side(&self) -> BookSide {
        match self.side_order {
            BookSideOrder::AsksAbove => BookSide::Bid,
            BookSideOrder::BidsAbove => BookSide::Ask,
        }
    }

    /// The row index under a screen y, if any.
    ///
    /// Bounded by the widget's own rectangle on both edges, matching `TreeTable::row_at`.
    ///
    /// The upper bound is defensive rather than a live fix: with today's `row_height`
    /// (which scales as `height / (depth * 2)`) a pointer below the widget always lands
    /// beyond the book's combined row count, so the data bound already rejected it. It is
    /// stated explicitly so the rejection does not depend on that coincidence surviving a
    /// future change to the row-height formula.
    ///
    /// The mapping follows the drawn layout: the upper block comes first (top to bottom,
    /// with that block's best price last because it is drawn reversed), then the lower
    /// block in its best-first order. The returned index always points into
    /// `level_slice(side)`, and which block is upper follows [`Self::side_order`].
    fn row_at(&self, y: i32) -> Option<(BookSide, usize)> {
        let geometry = self.base.geometry();
        if y < geometry.y || y >= geometry.y + geometry.height as i32 {
            return None;
        }
        let row_height = self.row_height();
        let offset = y - geometry.y;
        if offset < 0 {
            return None;
        }
        let row = (offset / row_height) as usize;
        let upper = self.first_side();
        let (upper_start, upper_levels) = self.level_window(upper);
        let upper_rows = upper_levels.len();
        if row < upper_rows {
            // The upper block is drawn from its deepest level down to the best one, so screen
            // row 0 is the last slice entry and the row adjacent to the middle is index 0.
            // The reported index is absolute, so a scrolled ladder names the level it shows.
            return Some((upper, upper_start + upper_rows - 1 - row));
        }
        let lower = self.second_side();
        let (lower_start, lower_levels) = self.level_window(lower);
        let lower_row = row - upper_rows;
        if lower_row < lower_levels.len() {
            return Some((lower, lower_start + lower_row));
        }
        None
    }
}

/// Writes a price at a fixed number of decimals into `out`.
///
/// Written out rather than using a dynamic-precision formatter, which the `alloc`-backed
/// `format!` in this crate does not provide. Shared by the ladder's two columns so the
/// price and the size cannot disagree about their alignment.
fn write_price(value: f64, decimals: usize, out: &mut alloc::string::String) {
    if !value.is_finite() {
        out.push('—');
        return;
    }
    let factor = 10f64.powi(decimals as i32);
    let scaled = (value * factor).round() as i64;
    if scaled < 0 {
        out.push('-');
    }
    let digits = scaled.unsigned_abs();
    if decimals == 0 {
        let _ = write!(out, "{digits}");
        return;
    }
    let digits_len = price_digit_count(digits);
    if digits_len <= decimals {
        // Matches the padded form: "." followed by enough zeros to reach `decimals` places.
        out.push('.');
        for _ in 0..(decimals - digits_len) {
            out.push('0');
        }
        let _ = write!(out, "{digits}");
    } else {
        let divisor = 10u64.pow(decimals as u32);
        let whole = digits / divisor;
        let fraction = digits % divisor;
        let _ = write!(out, "{whole}.");
        let fraction_digits = price_digit_count(fraction);
        for _ in 0..(decimals - fraction_digits) {
            out.push('0');
        }
        let _ = write!(out, "{fraction}");
    }
}

/// The number of decimal digits in `value` (`0` has one).
fn price_digit_count(mut value: u64) -> usize {
    let mut digits = 1;
    while value >= 10 {
        value /= 10;
        digits += 1;
    }
    digits
}

/// Formats a price at a fixed number of decimals, for callers that need an owned string.
fn format_price(value: f64, decimals: usize) -> alloc::string::String {
    let mut out = alloc::string::String::new();
    write_price(value, decimals, &mut out);
    out
}

impl Widget for OrderBookWidget {
    fn base(&self) -> &BaseWidget {
        &self.base
    }
    fn set_state_theme_hook(&mut self) {
        crate::style::reapply_active_theme_state(self);
    }
    fn base_mut(&mut self) -> &mut BaseWidget {
        &mut self.base
    }
    fn as_draw_mut(&mut self) -> Option<&mut dyn Draw> {
        Some(self)
    }

    /// Fades the quantity-change flashes.
    fn tick(&mut self, delta_ms: u32) -> bool {
        if !self.is_flashing() {
            return false;
        }
        let decay = (delta_ms / 4).min(255) as u8;
        for alpha in &mut self.flash_bid {
            *alpha = alpha.saturating_sub(decay);
        }
        for alpha in &mut self.flash_ask {
            *alpha = alpha.saturating_sub(decay);
        }
        self.base.request_redraw();
        true
    }

    /// Whether a flash is still fading.
    fn is_animating(&self) -> bool {
        self.is_flashing()
    }

    impl_widget_property_hooks!();
    /// Resolves a published event name to the control's own signal.
    ///
    /// # Why this is explicit
    ///
    /// `connect_event` validates a name against the capability table and registers a hub slot; only
    /// `event_signal_dyn` joins that name to the signal the control actually **emits**. Without an
    /// arm a published name is valid and inert, which is the silent failure
    /// `tools/check_event_signal_dyn.sh` exists to make impossible: the arm set is compared against
    /// the capability's published names, so the two cannot drift.
    fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
        use crate::signal::EventSignalRef;
        match name {
            "level_clicked" => {
                Some(EventSignalRef::mapped("level_clicked", &self.level_clicked, |v| {
                    CapabilityValue::Tuple(alloc::vec![
                        CapabilityValue::String(format!("{:?}", v.0)),
                        CapabilityValue::UInt(v.1 as u64),
                    ])
                }))
            }
            "level_hovered" => {
                Some(EventSignalRef::mapped("level_hovered", &self.level_hovered, |v| {
                    CapabilityValue::Tuple(alloc::vec![
                        CapabilityValue::String(format!("{:?}", v.0)),
                        CapabilityValue::UInt(v.1 as u64),
                    ])
                }))
            }
            "level_unhovered" => {
                Some(EventSignalRef::mapped("level_unhovered", &self.level_unhovered, |v| {
                    CapabilityValue::Tuple(alloc::vec![
                        CapabilityValue::String(format!("{:?}", v.0)),
                        CapabilityValue::UInt(v.1 as u64),
                    ])
                }))
            }
            _ => None,
        }
    }
}

impl crate::event::EventHandler for OrderBookWidget {
    fn handle_event(&mut self, event: &crate::event::Event) {
        use crate::event::Event;
        match event {
            Event::MouseMove { pos } | Event::PointerMove { pos, .. } => {
                let next = self.row_at(pos.y);
                if next != self.hovered {
                    if let Some(previous) = self.hovered {
                        self.level_unhovered.emit(previous);
                    }
                    if let Some(row) = next {
                        self.level_hovered.emit(row);
                    }
                    self.hovered = next;
                    self.base.request_redraw();
                }
            }
            Event::Wheel { delta, .. } if delta.y != 0 => {
                // One level per notch, the same direction the quote board scrolls. The
                // offset is clamped to the deeper side, so both ladders stay aligned.
                self.scroll_by(if delta.y > 0 { 1 } else { -1 });
            }
            Event::MouseLeave { .. } => {
                if let Some(previous) = self.hovered.take() {
                    self.level_unhovered.emit(previous);
                    self.base.request_redraw();
                }
            }
            Event::MousePress { pos, .. } | Event::PointerPress { pos, .. } => {
                if let Some(row) = self.row_at(pos.y) {
                    self.level_clicked.emit(row);
                }
            }
            _ => {}
        }
        self.base.handle_event(event);
    }
}

impl Draw for OrderBookWidget {
    fn draw(&mut self, context: &mut RenderContext) {
        let geometry = self.base.geometry();
        if geometry.width == 0 || geometry.height == 0 || self.depth == 0 {
            return;
        }

        // The panel is chrome and resolves the explicit style first, then the theme's resolved
        // style for this control, and only then a literal. It used to be a fixed near-black, so a
        // light/dark switch left the ladder's backdrop — its dominant colour — identical and the
        // rendering census reported the control as theme-blind. The bid/ask row colours and the
        // price text remain the caller's data colours; only the surface, the hover row and the
        // secondary chrome move with the theme.
        //
        // The theme read is a separate manager lock, taken and released inside
        // `resolved_theme_style`, so it is not held across the draw — the global manager's mutex
        // is not re-entrant.
        let style = self.base.style();
        let theme = crate::style::resolved_theme_style_shared("order_book");
        // Read as its own lock acquisition and copied out as values, so the guard is dropped
        // before anything else touches the theme.
        let (window_fill, foreground, secondary) = {
            let manager = crate::style::theme_manager();
            match manager.current_theme() {
                Some(active) => {
                    (active.colors.background, active.colors.foreground, active.colors.secondary)
                }
                None => (Color::rgb(240, 240, 240), Color::BLACK, Color::rgb(158, 158, 158)),
            }
        };
        let ink = style
            .text_color
            .or_else(|| theme.as_ref().and_then(|t| t.text_color))
            .unwrap_or(foreground);
        // `order_book` is absent from `WidgetRole::for_kind_name`'s table, so it classifies as
        // `Surface` and the active theme writes the window fill into `style.background_color`.
        // A panel painted in that colour would be byte-identical to the frame behind it, so a
        // resolved surface equal to the window fill is re-derived a visible step away from it,
        // while a colour the caller set still wins.
        let panel = match style.background_color {
            Some(resolved) if resolved != window_fill => resolved,
            _ => window_fill.blend(&ink, 0.08),
        };
        context.fill_rect(geometry, panel);

        // A hovered row and the spread rule are raised from the panel, so they read on either
        // appearance rather than being a fixed slate that only worked on a dark backdrop.
        let hover_fill = panel.blend(&ink, 0.12);
        let rule_color = panel.blend(&secondary, 0.45);

        // BLUE23 §4.6 (BLUE21 D5): an empty book must still say so.
        //
        // The two loops below simply draw nothing for an empty side, which left the panel as a
        // bare slab — the same "silent gap" the finance siblings had. This reuses the shared
        // [`draw_empty_pane`], so the message and its contrast floor are one derivation across
        // every chart in this family rather than a second copy here.
        let has_levels =
            [BookSide::Bid, BookSide::Ask].iter().any(|side| !self.level_slice(*side).is_empty());
        if !has_levels {
            let area = PlotArea::of(geometry);
            // The empty pane's frame follows the table's own row rule rather than the plot
            // panes' ink-derived grid, so an empty book is chrome-identical to a full one.
            draw_empty_pane(
                context,
                &area,
                crate::widget::special_widgets::finance::layout::PanelColors {
                    grid: rule_color,
                    ..crate::widget::special_widgets::finance::layout::PanelColors::from_parts(
                        panel, ink,
                    )
                },
                &role_font(style, 12.0),
            );
            return;
        }

        let (decimals, max_quantity) = self.visible_facts();
        // Cumulative depth per side is cached when the book or depth changes (see
        // `rebuild_cumulative`), so a repaint only reads the two prefix-sum arrays. The bar
        // answers "how much is available up to this price" when the mode is on.
        let row_height = self.row_height();
        // The size column occupies the middle band; prices sit outside it, so a long bar
        // cannot run under a price and make it unreadable.
        let spine = geometry.x + geometry.width as i32 / 2;
        // The two text columns come **out of** the bar's share, not out of the price's.
        //
        // The span used to be `width / 2 - 40`, which let the bar run to within 40 px of the
        // panel's edge — where the price already lives. On the demo's 480 px ladder the price was
        // left 30 px, so every bid showed `12...`: the numbers the ladder exists for were elided
        // away. Reserving the price and quantity columns first is what keeps `121.30` readable at
        // any width; a narrow ladder gets a shorter bar, which is the dimension that can afford it.
        const PRICE_COLUMN: i32 = 44;
        const QUANTITY_COLUMN: i32 = 42;
        const COLUMN_GAP: i32 = 6;
        let bar_span =
            (geometry.width as i32 / 2 - PRICE_COLUMN - QUANTITY_COLUMN - COLUMN_GAP * 2).max(8);

        // The upper block is drawn first, stacked above the middle; the lower block follows.
        // The upper block is drawn from its deepest level up to its best one, so the best
        // price lands in the row touching the spread rule; the lower block keeps its
        // best-first order, so its best price lands in the row just below the rule.
        // One font for the ladder: `role_font` allocates its family `String`, and it was rebuilt
        // twice per level per frame.
        let level_font = role_font(self.style(), 11.0);
        // Two buffers for the whole ladder: formatting a price and a quantity into them reuses
        // the allocations instead of building two fresh `String`s for every level per frame.
        let mut price_text = alloc::string::String::new();
        let mut quantity_text = alloc::string::String::new();
        let mut row = 0;
        for (block, side) in [self.first_side(), self.second_side()].into_iter().enumerate() {
            let (window_start, levels) = self.level_window(side);
            let color = if side == BookSide::Bid { self.bid_color } else { self.ask_color };
            let level_count = levels.len();
            // The upper block runs deepest-to-best while the lower block runs best-first. One
            // reverse-aware index keeps a single row body for both sides; splitting into
            // `.iter()` and `.iter().rev()` would duplicate the row painter.
            #[allow(clippy::needless_range_loop)]
            for step in 0..level_count {
                let local = if block == 0 { level_count - 1 - step } else { step };
                let index = window_start + local;
                let level = &levels[local];
                let y = geometry.y + row * row_height;
                let is_hovered = self.hovered == Some((side, index));
                if is_hovered {
                    context.fill_rect(
                        Rect::new(geometry.x, y, geometry.width, row_height as u32),
                        hover_fill,
                    );
                }
                // A changed quantity flashes in its side's colour, fading as `tick` runs. The
                // alpha is indexed by level, so this is a lookup rather than a scan per row.
                let flash_alpha = match side {
                    BookSide::Bid => self.flash_bid.get(index).copied(),
                    BookSide::Ask => self.flash_ask.get(index).copied(),
                };
                if let Some(alpha) = flash_alpha.filter(|alpha| *alpha > 0) {
                    let flash = if side == BookSide::Bid { self.bid_color } else { self.ask_color };
                    context.fill_rect(
                        Rect::new(geometry.x, y, geometry.width, row_height as u32),
                        flash.with_alpha(alpha),
                    );
                }

                // The size bar, growing outward from the spine.
                let cumulative = match side {
                    BookSide::Bid => &self.cumulative_bid,
                    BookSide::Ask => &self.cumulative_ask,
                };
                let size = if self.cumulative_depth {
                    cumulative.get(index).copied().unwrap_or(level.quantity)
                } else {
                    level.quantity
                };
                let scale = if self.cumulative_depth { self.cumulative_max } else { max_quantity };
                if scale > 0.0 && size.is_finite() && size > 0.0 {
                    let length = ((size / scale) * bar_span as f64).round().max(1.0) as i32;
                    let (bar_x, bar_width) = match side {
                        BookSide::Bid => (spine - length, length as u32),
                        BookSide::Ask => (spine, length as u32),
                    };
                    context.fill_rect(
                        Rect::new(bar_x, y + 1, bar_width, (row_height - 2).max(1) as u32),
                        color,
                    );
                }

                price_text.clear();
                write_price(level.price, decimals, &mut price_text);
                quantity_text.clear();
                if level.quantity.is_finite() {
                    let _ = write!(quantity_text, "{:.0}", level.quantity);
                } else {
                    quantity_text.push('—');
                }

                // Price on the outer edge, quantity beside the bar, so the two columns
                // align vertically across every row. Each is bounded to the gap it owns:
                // the price to the spine/bar (bids) or the panel edge (asks), and the
                // quantity to the space between the bar and the price column. On the
                // narrow ladders where those two would collide, the quantity is omitted —
                // the bar already encodes size, and painting a number over the price (or
                // past the panel edge) was the defect this bound removes.
                let (price_x, quantity_x) = match side {
                    BookSide::Bid => {
                        (geometry.x + COLUMN_GAP, spine - bar_span - QUANTITY_COLUMN - 4)
                    }
                    // Mirrored, not a fixed 58 px inset: the ask side's price column has to be the
                    // same width as the bid side's, or the two ladders show numbers in two
                    // different font sizes' worth of space and the longer one elides.
                    BookSide::Ask => (
                        geometry.x + geometry.width as i32 - PRICE_COLUMN - COLUMN_GAP,
                        spine + bar_span + 4,
                    ),
                };
                let text_y = y + (row_height - 12) / 2;
                // The quantity column stops short of the bar on the bid side, and short
                // of the price column on the ask side.
                let quantity_right = match side {
                    BookSide::Bid => spine - bar_span - 4,
                    BookSide::Ask => price_x - 4,
                };
                let quantity_drawn = quantity_x > geometry.x
                    && quantity_right > quantity_x
                    && quantity_x < geometry.x + geometry.width as i32 - 8;
                let price_right = match side {
                    BookSide::Bid => {
                        if quantity_drawn {
                            quantity_x - 4
                        } else {
                            spine - bar_span - 4
                        }
                    }
                    BookSide::Ask => geometry.x + geometry.width as i32 - 2,
                };
                let price_width = ((price_right - price_x).max(0)) as u32;
                context.draw_text_fitted_cow(
                    Rect::new(price_x, text_y, price_width.max(1), 12),
                    &price_text,
                    &level_font,
                    color,
                    HorizontalAlignment::Left,
                );
                if quantity_drawn {
                    // The quantity is neutral body text. It used to read a light-on-dark
                    // literal stored at construction, so on a light theme the ladder's
                    // sizes were painted in a colour barely distinguishable from the panel.
                    let quantity_width = (quantity_right - quantity_x) as u32;
                    context.draw_text_fitted_cow(
                        Rect::new(quantity_x, text_y, quantity_width.max(1), 12),
                        &quantity_text,
                        &level_font,
                        secondary,
                        HorizontalAlignment::Left,
                    );
                }
                row += 1;
            }
        }

        // The spread, drawn across the middle where the two blocks meet.
        if let Some(spread) = self.book.spread() {
            let upper_rows = self.level_slice(self.first_side()).len();
            let y = geometry.y + upper_rows as i32 * row_height;
            context.draw_line_stroke(
                Point { x: geometry.x, y },
                Point { x: geometry.x + geometry.width as i32, y },
                rule_color,
                1,
            );
            let text = alloc::format!("spread {}", format_price(spread, decimals));
            // Centred on the spine rather than started at the frame's left margin: at that
            // margin the label sat on top of the last bid's price (the census sample read
            // `99.5spread 0.1`), because the row above the rule still owns that column. The
            // spine is the one place in the middle band that neither side's text occupies —
            // and because the size bars do reach it, the label is laid on a piece of the panel
            // first, so it is never read against a bid or an ask colour.
            let font = role_font(self.style(), 10.0);
            let label_w = context.measure_text(&text, &font).width as i32;
            context.fill_rect(
                Rect::new(spine - label_w / 2 - 3, y - 14, (label_w + 6).max(1) as u32, 13),
                panel,
            );
            context.draw_text(
                Point { x: spine, y: y - 13 },
                &text,
                &font,
                ink.blend(&panel, 0.45),
                HorizontalAlignment::Center,
            );
        }
    }
}

/// Test module for the order book ladder.
impl WidgetProperties for OrderBookWidget {
    fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
        match name {
            "depth" => Ok(CapabilityValue::UInt(self.depth as u64)),
            "decimals" => match self.decimals {
                Some(decimals) => Ok(CapabilityValue::UInt(decimals as u64)),
                // `Null` rather than a sentinel integer: "infer it" is not a number of
                // decimals, and reporting a zero would read as "no decimals".
                None => Ok(CapabilityValue::Null),
            },
            "show_spread" => Ok(CapabilityValue::Bool(true)),
            _ => base_property_get(self, name),
        }
    }

    fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
        match name {
            "depth" => {
                let CapabilityValue::UInt(depth) = value else {
                    return Err(CapabilityAccessError::TypeMismatch);
                };
                self.set_depth(depth as usize);
                Ok(())
            }
            "decimals" => match value {
                CapabilityValue::UInt(decimals) => {
                    self.set_decimals(Some(decimals as usize));
                    Ok(())
                }
                // Writing `Null` is the documented way to ask for inference again.
                CapabilityValue::Null => {
                    self.set_decimals(None);
                    Ok(())
                }
                _ => Err(CapabilityAccessError::TypeMismatch),
            },
            "show_spread" => {
                // The spread separator is part of what makes the ladder readable, so it
                // is not switchable; the property exists so a caller can confirm it is
                // on, and **every** write is refused as read-only.
                //
                // The type is deliberately not inspected first. Checking it and answering
                // `TypeMismatch` for a non-bool made the control's answer depend on the
                // *value* of a write that can never succeed: a caller writing `Null` (or
                // any other type) was told "wrong type", which invites them to retry with
                // a bool — and only then learn the name is read-only. The declaration says
                // `writable: false`, so `ReadOnlyProperty` is the one honest answer and it
                // must not depend on what was passed.
                Err(CapabilityAccessError::ReadOnlyProperty)
            }
            _ => base_property_set(self, name, value),
        }
    }

    fn property_names(&self) -> &'static [&'static str] {
        property_names_of!["depth", "decimals", "show_spread", BASE_PROPERTY_NAMES]
    }

    /// Runs one of the commands `order_book` publishes.
    ///
    /// A ladder is a table of levels, not a plotted series, so it has no overlay
    /// model; `add_overlay` is published for the whole finance group and is answered
    /// as [`CapabilityAccessError::UnsupportedOnWidget`] — the name is real and this
    /// control cannot perform it. That refusal is an honest answer, not a fix: the
    /// `commands` list still advertises an action this control cannot take, and
    /// reconciling it is a registry decision. `set_series` / `set_depth` /
    /// `set_decimals` carry their values and are refused as
    /// [`CapabilityAccessError::OutOfRange`].
    fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
        match name {
            "add_overlay" => Err(CapabilityAccessError::UnsupportedOnWidget),
            "set_series" | "set_depth" | "set_decimals" => Err(CapabilityAccessError::OutOfRange),
            _ => Err(CapabilityAccessError::UnknownCommand),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::{Event, EventHandler};
    use crate::widget::special_widgets::finance::types::fixtures;
    use alloc::sync::Arc;
    use core::sync::atomic::{AtomicUsize, Ordering};

    /// The ladder carries the book it was given.
    #[test]
    fn a_ladder_carries_its_book() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        assert!(ladder.book().bids().is_empty());
        ladder.set_book(fixtures::sample_book(5));
        assert_eq!(ladder.book().bids().len(), 5);
    }

    /// The default depth is five, matching the conventional published depth.
    #[test]
    fn the_default_depth_is_five() {
        let ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        assert_eq!(ladder.depth(), DEFAULT_BOOK_DEPTH);
        assert_eq!(ladder.depth(), 5);
    }

    /// The levels shown are capped at the depth on each side.
    #[test]
    fn levels_are_capped_at_the_depth() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        ladder.set_book(fixtures::sample_book(10));
        assert_eq!(ladder.levels(BookSide::Bid).len(), 5);
        assert_eq!(ladder.levels(BookSide::Ask).len(), 5);
        ladder.set_depth(2);
        assert_eq!(ladder.levels(BookSide::Bid).len(), 2);
        assert_eq!(ladder.levels(BookSide::Ask).len(), 2);
    }

    /// The bids are best-first (highest price) and the asks best-first (lowest).
    ///
    /// This is the property the whole ladder is read by: the top row of each side is the
    /// spread. If the book held arrival order instead, the top row would be arbitrary.
    #[test]
    fn both_sides_are_ordered_best_first() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        let mut book = crate::widget::special_widgets::finance::types::OrderBook::new();
        // Deliberately out of order, as a feed delivers them.
        book.set_bids(alloc::vec![
            crate::widget::special_widgets::finance::types::BookLevel::new(97.0, 1.0),
            crate::widget::special_widgets::finance::types::BookLevel::new(99.0, 1.0),
            crate::widget::special_widgets::finance::types::BookLevel::new(98.0, 1.0),
        ]);
        book.set_asks(alloc::vec![
            crate::widget::special_widgets::finance::types::BookLevel::new(103.0, 1.0),
            crate::widget::special_widgets::finance::types::BookLevel::new(101.0, 1.0),
            crate::widget::special_widgets::finance::types::BookLevel::new(102.0, 1.0),
        ]);
        ladder.set_book(book);
        let bids = ladder.levels(BookSide::Bid);
        let asks = ladder.levels(BookSide::Ask);
        assert!((bids[0].price - 99.0).abs() < 1e-9, "the best bid is the highest");
        assert!(bids[0].price > bids[1].price && bids[1].price > bids[2].price);
        assert!((asks[0].price - 101.0).abs() < 1e-9, "the best ask is the lowest");
        assert!(asks[0].price < asks[1].price && asks[1].price < asks[2].price);
    }

    /// An explicit precision overrides the inference and is capped.
    #[test]
    fn decimals_can_be_forced_and_are_capped() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        ladder.set_book(fixtures::sample_book(5));
        assert_eq!(ladder.decimals(), None, "inferred by default");
        ladder.set_decimals(Some(2));
        assert_eq!(ladder.decimals(), Some(2));
        ladder.set_decimals(Some(99));
        assert_eq!(ladder.decimals(), Some(6), "capped at the readable maximum");
        ladder.set_decimals(None);
        assert_eq!(ladder.decimals(), None);
    }

    /// Colours are stored and returned.
    #[test]
    fn colours_round_trip() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        let (bid, ask) = ladder.colors();
        assert_ne!(bid, ask);
        ladder.set_colors(crate::core::Color::rgb(1, 2, 3), crate::core::Color::rgb(4, 5, 6));
        assert_eq!(
            ladder.colors(),
            (crate::core::Color::rgb(1, 2, 3), crate::core::Color::rgb(4, 5, 6))
        );
    }

    /// An empty book draws without panicking.
    #[test]
    fn an_empty_book_draws_without_panicking() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        let mut backend =
            crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 240), 1.0);
        let mut context = RenderContext::new(&mut backend);
        ladder.draw(&mut context);
    }

    /// An empty book draws the **`No data` message**, not just a panel.
    ///
    /// BLUE23 §4.6 (BLUE21 D5). "Draws without panicking" is satisfied by a bare slab, which is
    /// exactly what this control used to produce: the two row loops iterate over an empty book and
    /// paint nothing. So the assertion is on the ink the message leaves, through the SVG backend —
    /// the same backend the snapshot gate reads, so what this proves is what a reviewer sees.
    ///
    /// The signal is the **message**, which `text_line` centres in the pane: measured, it lands at
    /// y `108..118` in a 240 px pane — the *ink* band, which sits just above the geometric middle
    /// because `text_line` offsets by whole glyph boxes. So the band is asserted as "in the middle
    /// third", which is what distinguishes a centred message from rows drawn from the top down.
    #[test]
    fn an_empty_book_draws_the_no_data_message() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        let empty_svg =
            crate::widget::svg::render_widget_to_svg(&mut ladder, Rect::new(0, 0, 320, 240));
        // The middle third of a 240 px pane: 80..=160. A message is centred here; the rows of a
        // populated book start at the top (bids first) and never land here for a 5-level book.
        let (low, high) = (80, 160);
        let centred_runs = crate::widget::svg::text_ink_boxes(&empty_svg)
            .into_iter()
            .filter(|(_, top, _, bottom)| *top >= low && *bottom <= high)
            .count();
        assert!(
            centred_runs > 0,
            "an empty book must say so: the pane owes the reader a centred message, \
             got {centred_runs} runs in y {low}..={high}"
        );

        // And the message is a *message*: one short run, not a table's worth of cells.
        assert_eq!(
            crate::widget::svg::text_ink_boxes(&empty_svg).len(),
            1,
            "the empty state is one message, not a grid"
        );

        let mut filled = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        filled.set_book(fixtures::sample_book(5));
        let filled_svg =
            crate::widget::svg::render_widget_to_svg(&mut filled, Rect::new(0, 0, 320, 240));
        assert!(
            crate::widget::svg::text_subpath_count(&filled_svg)
                > crate::widget::svg::text_subpath_count(&empty_svg),
            "a populated book must draw more ink than the empty-state message"
        );
    }

    /// A zero depth draws nothing rather than panicking on a division by zero.
    #[test]
    fn a_zero_depth_draws_nothing() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        ladder.set_book(fixtures::sample_book(5));
        ladder.set_depth(0);
        assert!(ladder.levels(BookSide::Bid).is_empty());
        let mut backend =
            crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 240), 1.0);
        let mut context = RenderContext::new(&mut backend);
        ladder.draw(&mut context);
    }

    /// A malformed book draws without panicking.
    #[test]
    fn a_malformed_book_draws_without_panicking() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        let mut book = crate::widget::special_widgets::finance::types::OrderBook::new();
        book.set_bids(alloc::vec![
            crate::widget::special_widgets::finance::types::BookLevel::new(f64::NAN, f64::NAN),
            crate::widget::special_widgets::finance::types::BookLevel::new(99.0, f64::INFINITY),
        ]);
        ladder.set_book(book);
        let mut backend =
            crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 240), 1.0);
        let mut context = RenderContext::new(&mut backend);
        ladder.draw(&mut context);
    }

    /// Clicking a row emits its side and index.
    #[test]
    fn clicking_a_row_emits_its_position() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        ladder.set_book(fixtures::sample_book(5));
        let count = Arc::new(AtomicUsize::new(0));
        let sink = Arc::clone(&count);
        ladder.level_clicked.connect(move |_| {
            sink.fetch_add(1, Ordering::SeqCst);
        });
        ladder.handle_event(&crate::event::Event::MousePress {
            pos: crate::core::Point { x: 100, y: 10 },
            button: 0,
            modifiers: 0,
        });
        assert_eq!(count.load(Ordering::SeqCst), 1, "a press on a row emits it");
    }

    /// The hovered row clears when the book shrinks past it.
    #[test]
    fn replacing_a_shorter_book_clears_a_stale_hover() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        ladder.set_book(fixtures::sample_book(5));
        ladder.handle_event(&crate::event::Event::MouseMove {
            pos: crate::core::Point { x: 100, y: 10 },
        });
        ladder.set_book(fixtures::sample_book(1));
        assert!(
            ladder.hovered().is_none_or(|(_, index)| index < 1),
            "a hover must not survive past the book it pointed into"
        );
    }
    /// Hover is bounded by the widget's own rectangle, not only by the book's depth.
    ///
    /// `row_at` rejected a negative offset but had no upper bound, so whether a pointer below
    /// the ladder resolved was left to the book's depth. Today the row-height formula makes
    /// that coincidentally safe (a row below the widget always exceeds the book's row count),
    /// and this pins the property so it does not depend on that coincidence: the widget is
    /// short and the book far deeper than it can show.
    #[test]
    fn order_book_hover_is_bounded_by_the_geometry() {
        let geometry = Rect::new(0, 50, 300, 100);
        let hover_at = |y: i32| {
            let mut ladder = OrderBookWidget::new(geometry);
            ladder.set_depth(64);
            ladder.set_book(fixtures::sample_book(64));
            ladder.handle_event(&Event::MouseMove { pos: Point::new(150, y) });
            ladder.hovered()
        };

        assert_eq!(hover_at(49), None, "above the widget");
        assert!(hover_at(60).is_some(), "the top rows are inside the widget");
        assert_eq!(hover_at(150), None, "the bottom edge is outside");
        assert_eq!(hover_at(200), None, "below the widget");
        assert_eq!(hover_at(2000), None, "far below the widget");
    }

    /// The ask row touching the middle is the best (lowest) ask.
    ///
    /// Five levels per side in a 240 px ladder give 24 px rows: ask rows 0..=4,
    /// so row 4 (y 96..120) is the bottom ask row and must hold `asks()[0]`.
    #[test]
    fn the_ask_row_touching_the_middle_is_the_best_ask() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        ladder.set_book(fixtures::sample_book(5));

        ladder.handle_event(&Event::MouseMove { pos: Point::new(100, 108) });
        let (side, index) = ladder.hovered().expect("the best ask row is inside the widget");
        assert_eq!(side, BookSide::Ask);
        assert_eq!(
            ladder.levels(BookSide::Ask)[index].price,
            ladder.book().asks()[0].price,
            "the ask row adjacent to the middle must be the lowest ask"
        );
    }

    /// Clicking the first row below the middle reports the best bid.
    #[test]
    fn clicking_the_first_row_below_the_middle_selects_the_best_bid() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        ladder.set_book(fixtures::sample_book(5));

        let side = Arc::new(AtomicUsize::new(usize::MAX));
        let index = Arc::new(AtomicUsize::new(usize::MAX));
        let side_sink = Arc::clone(&side);
        let index_sink = Arc::clone(&index);
        ladder.level_clicked.connect(move |value| {
            side_sink.store(
                match value.0 {
                    BookSide::Ask => 0,
                    BookSide::Bid => 1,
                },
                Ordering::SeqCst,
            );
            index_sink.store(value.1, Ordering::SeqCst);
        });

        // Ask rows occupy 0..=4; y 120..144 is the first bid row.
        ladder.handle_event(&Event::MousePress {
            pos: Point::new(100, 132),
            button: 0,
            modifiers: 0,
        });
        assert_eq!(side.load(Ordering::SeqCst), 1, "the row below the middle is a bid");
        assert_eq!(index.load(Ordering::SeqCst), 0, "it is the best bid");
    }

    /// Hover payload indices stay aligned with `levels(side)`'s best-first order.
    #[test]
    fn hover_indices_follow_the_level_order() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        ladder.set_book(fixtures::sample_book(5));

        let mut ask_indices = alloc::vec::Vec::new();
        let mut bid_indices = alloc::vec::Vec::new();
        for row in 0..10 {
            let y = row * 24 + 12;
            ladder.handle_event(&Event::MouseMove { pos: Point::new(100, y) });
            let (side, index) = ladder.hovered().expect("every ladder row maps to a level");
            match side {
                BookSide::Ask => ask_indices.push(index),
                BookSide::Bid => bid_indices.push(index),
            }
        }

        assert_eq!(ask_indices, alloc::vec![4, 3, 2, 1, 0], "asks run deepest to best");
        assert_eq!(bid_indices, alloc::vec![0, 1, 2, 3, 4], "bids run best first");
    }

    /// The side order can put bids on top while each ladder keeps its own ordering.
    #[test]
    fn side_order_can_stack_bids_above_asks() {
        let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
        ladder.set_book(fixtures::sample_book(3));
        ladder.set_depth(3);
        assert_eq!(ladder.side_order(), BookSideOrder::AsksAbove);
        assert_eq!(ladder.row_at(1).map(|(side, _)| side), Some(BookSide::Ask));

        ladder.set_side_order(BookSideOrder::BidsAbove);
        assert_eq!(ladder.side_order(), BookSideOrder::BidsAbove);
        // The upper block is drawn deepest-first, so its best level is its last row.
        assert_eq!(ladder.row_at(1), Some((BookSide::Bid, 2)));
        // The lower block is drawn best-first, so its best level touches the rule.
        let rule_row = 3 * ladder.row_height();
        assert_eq!(ladder.row_at(rule_row + 1), Some((BookSide::Ask, 0)));
    }
}