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
//! GTK rasteriser for [`crate::primitives::pipeline_view::PipelineView`].
//!
//! Painting is implemented in the shared
//! [`crate::primitives::pipeline_view::native_surface_paint::paint`].
//! This module carries [`gtk_pipeline_view_layout`], the backend-specific
//! pure geometry computation (no painting involved).
use crate::primitives::layout_metrics::pixel_pipeline_view_layout;
use crate::primitives::pipeline_view::{PipelineView, PipelineViewLayout};
#[cfg(test)]
use crate::theme::Theme;
/// Compute the GTK pixel-unit layout for a [`PipelineView`] without
/// painting. Shares its geometry with `mac_pipeline_view_layout` /
/// `win_pipeline_view_layout` via [`pixel_pipeline_view_layout`] (issue
/// #1079).
///
/// Note: the returned layout (incl. `bounds`) is offset down by
/// [`crate::primitives::layout_metrics::pixel::PIPELINE_FOCUS_INDICATOR_H`],
/// so `bounds.y` starts below the reserved caret strip. The focus caret is
/// painted in the gap between the passed-in `y` and `bounds.y`; a host
/// that clips drawing to `layout.bounds` would clip the caret — clip to
/// the original `(y, h)` instead.
pub fn gtk_pipeline_view_layout(
view: &PipelineView,
x: f64,
y: f64,
w: f64,
h: f64,
) -> PipelineViewLayout {
pixel_pipeline_view_layout(view, x as f32, y as f32, w as f32, h as f32)
}
// ── Tests ──────────────────────────────────────────────────────────────────
//
// Headless painted-indicator tests using a Cairo `ImageSurface` (no display
// required) and reading back pixels directly. The focus indicator is a
// two-line chevron; these tests probe a point on one of the two strokes.
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Rect as QRect;
use crate::gtk::backend::GtkBackend;
use crate::primitives::pipeline_view::{PipelineStage, PipelineViewLayout, StageStatus};
use crate::types::WidgetId;
use crate::Backend;
use pangocairo::cairo::{Context, Format, ImageSurface};
// Surface large enough to contain the box plus the reserved caret strip.
const W: i32 = 240;
const H: i32 = 80;
const BOX_W: f64 = 200.0;
const BOX_H: f64 = 50.0;
/// Read an RGB triple from an ARgb32 surface at pixel (x, y).
///
/// Cairo's `ARgb32` stores each pixel as four bytes in native
/// (little-endian) byte order: [B, G, R, A]. `stride` is in bytes and may
/// include padding. All pixels painted here are opaque, so premultiplied
/// and straight alpha coincide.
fn pixel(data: &[u8], stride: usize, x: i32, y: i32) -> (u8, u8, u8) {
let off = y as usize * stride + x as usize * 4;
(data[off + 2], data[off + 1], data[off])
}
/// Squared Euclidean distance between two RGB triples.
fn dist2(a: (u8, u8, u8), b: (u8, u8, u8)) -> i64 {
let dr = a.0 as i64 - b.0 as i64;
let dg = a.1 as i64 - b.1 as i64;
let db = a.2 as i64 - b.2 as i64;
dr * dr + dg * dg + db * db
}
/// Find the most-chromatic pixel (max channel spread) in an inclusive
/// rectangular region. Used to locate the coloured border line, which is a
/// 1px antialiased stroke blended with its surroundings.
fn most_chromatic(
data: &[u8],
stride: usize,
x0: i32,
y0: i32,
x1: i32,
y1: i32,
) -> (u8, u8, u8) {
let mut best = (0u8, 0u8, 0u8);
let mut best_chroma = -1i32;
for y in y0..=y1 {
for x in x0..=x1 {
let (r, g, b) = pixel(data, stride, x, y);
let chroma = r.max(g).max(b) as i32 - r.min(g).min(b) as i32;
if chroma > best_chroma {
best_chroma = chroma;
best = (r, g, b);
}
}
}
best
}
fn make_view() -> PipelineView {
PipelineView {
id: WidgetId::new("pipe"),
stages: vec![PipelineStage {
label: "Build".into(),
status: StageStatus::Done,
action: None,
}],
focused_stage: None,
}
}
/// Paint a single Done stage into a fresh white surface with the given
/// focus state, via the real `Backend::draw_pipeline_view` path.
/// Returns the surface and the resolved layout so tests can derive box
/// geometry rather than hardcoding it.
fn paint(focused: Option<usize>) -> (ImageSurface, PipelineViewLayout) {
let surface = ImageSurface::create(Format::ARgb32, W, H).expect("create ImageSurface");
let layout;
{
let cr = Context::new(&surface).expect("Context::new");
// White background so any untouched pixel is clearly white.
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let mut view = make_view();
view.focused_stage = focused;
let mut backend = GtkBackend::new();
let painted = std::cell::RefCell::new(None);
backend.enter_frame_scope(&cr, &pango_layout, |b| {
*painted.borrow_mut() = Some(
b.draw_pipeline_view(QRect::new(20.0, 20.0, BOX_W as f32, BOX_H as f32), &view),
);
});
layout = painted.into_inner().expect("layout captured");
}
(surface, layout)
}
/// Midpoint of the chevron's left stroke — from `(ind_x - 5, by - 7)`
/// to `(ind_x, by - 1)` — comfortably clear of the box's top border
/// (whose 1px stroke straddles `by` itself and antialiases a pixel or
/// two above it, which would otherwise false-positive this probe).
fn caret_probe(layout: &PipelineViewLayout) -> (i32, i32) {
let bb = layout.stages[0].box_bounds;
let ind_x = bb.x + bb.width / 2.0;
let cx = (ind_x - 2.5).round() as i32;
let cy = (bb.y as f64 - 4.0).round() as i32;
(cx, cy)
}
/// Focused Done stage: the chevron tip is painted above the box AND
/// the box border keeps its per-status (`git_added`) colour rather
/// than the focus accent. This is the exact issue scenario — focus +
/// Done together.
#[test]
fn focused_done_stage_shows_indicator_and_retains_border() {
let (mut surface, layout) = paint(Some(0));
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let theme = Theme::default();
let git_added = (theme.git_added.r, theme.git_added.g, theme.git_added.b);
let accent = (theme.accent_bg.r, theme.accent_bg.g, theme.accent_bg.b);
let muted = (theme.muted_fg.r, theme.muted_fg.g, theme.muted_fg.b);
// (a) The chevron tip above the box is painted (not background
// white) and is the muted indicator colour.
let (cx, cy) = caret_probe(&layout);
let caret_px = pixel(&data, stride, cx, cy);
assert_ne!(
caret_px,
(255, 255, 255),
"focus chevron should paint above the box, got white"
);
assert!(
dist2(caret_px, muted) < 1500,
"chevron tip pixel {caret_px:?} should be ~muted_fg {muted:?}"
);
// (b) The box border renders in the Done colour, not the focus accent.
// Scan the straight left-border segment and find the coloured
// stroke pixel (a blended 1px line).
let bb = layout.stages[0].box_bounds;
let bx = bb.x.round() as i32;
let by = bb.y.round() as i32;
let bh = bb.height.round() as i32;
let border = most_chromatic(&data, stride, bx - 2, by + bh / 4, bx + 2, by + bh * 3 / 4);
assert!(
dist2(border, git_added) < dist2(border, accent),
"border {border:?} should be closer to git_added {git_added:?} \
than to accent_bg {accent:?}"
);
}
/// When no stage is focused the reserved strip above the box stays blank.
#[test]
fn no_indicator_when_not_focused() {
let (mut surface, layout) = paint(None);
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let (cx, cy) = caret_probe(&layout);
let px = pixel(&data, stride, cx, cy);
assert_eq!(
px,
(255, 255, 255),
"no focus → reserved strip above the box must stay background white, got {px:?}"
);
}
/// `pipeline_view_layout` is documented **ABSOLUTE**:
/// `box_bounds` / `action_bounds` must be shifted by the widget's
/// own origin, not left at (0, 0) — the case that hides a
/// LOCAL/ABSOLUTE mixup.
fn hit_test_round_trip_at(x: f64, y: f64) {
use crate::primitives::pipeline_view::PipelineHit;
let mut view = make_view();
view.stages[0].action = Some("Retry".into());
let layout = gtk_pipeline_view_layout(&view, x, y, 200.0, 50.0);
let stage = &layout.stages[0];
assert_eq!(stage.box_bounds.x as f64, x);
let action = stage.action_bounds.expect("action bounds present");
let acx = action.x + action.width / 2.0;
let acy = action.y + action.height / 2.0;
assert_eq!(layout.hit_test(acx, acy), PipelineHit::Action(0));
let bcx = stage.box_bounds.x + 2.0;
let bcy = stage.box_bounds.y + 2.0;
assert_eq!(layout.hit_test(bcx, bcy), PipelineHit::Body(0));
}
#[test]
fn hit_test_round_trip() {
hit_test_round_trip_at(0.0, 0.0);
}
/// Non-zero-origin regression guard.
#[test]
fn hit_test_round_trip_at_nonzero_origin() {
hit_test_round_trip_at(7.0, 13.0);
}
}