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
use crate::render::palette::RgbColor;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
/// Visual style of the background grid.
pub enum GridStyle {
/// Solid lines intersecting at grid points.
Cross,
/// Points only at intersections.
Dots,
/// Lines that fade out from the center.
Gradient,
}
impl FromStr for GridStyle {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"cross" => Ok(GridStyle::Cross),
"dots" => Ok(GridStyle::Dots),
"gradient" => Ok(GridStyle::Gradient),
_ => Err(format!("Unknown grid style: {}", s)),
}
}
}
#[derive(Clone)]
/// Renders a background grid to provide spatial reference.
pub struct GridRenderer {
/// Rendering style.
pub style: GridStyle,
/// Number of grid cells per axis; line spacing is derived from terminal size.
pub size: usize,
/// Grid line color.
pub color: RgbColor,
/// Base opacity of the grid.
pub opacity: f32,
/// Whether opacity adapts to content brightness.
pub adaptive: bool,
col_positions: Vec<usize>,
row_positions: Vec<usize>,
}
impl GridRenderer {
/// Creates a new grid renderer.
pub fn new(
style: GridStyle,
size: usize,
color: RgbColor,
opacity: f32,
adaptive: bool,
) -> Self {
Self {
style,
size,
color,
opacity,
adaptive,
col_positions: Vec::new(),
row_positions: Vec::new(),
}
}
/// Calculate grid line positions with center-heavy remainder distribution.
/// When terminal_size % grid_size != 0, the remainder is distributed to the center-most lines.
///
/// Example: 24 rows, grid_size 10:
/// - Base spacing: 24 / 10 = 2
/// - Remainder: 24 % 10 = 4
/// - Result: 2-2-2-3-3-3-3-2-2-2 (center 4 cells get +1 spacing)
fn calculate_grid_positions(terminal_size: usize, grid_size: usize) -> Vec<usize> {
if grid_size == 0 || terminal_size == 0 {
return Vec::new();
}
let base_spacing = terminal_size / grid_size;
let remainder = terminal_size % grid_size;
if base_spacing == 0 {
return Vec::new();
}
let mut positions = Vec::with_capacity(grid_size - 1);
let mut current_pos = 0;
// Center-heavy distribution: the middle `remainder` cells get +1 spacing
let start_extra = (grid_size - remainder) / 2;
let end_extra = start_extra + remainder;
for i in 0..grid_size {
let cell_height = if i >= start_extra && i < end_extra {
base_spacing + 1
} else {
base_spacing
};
current_pos += cell_height;
// Add line position (but not after the last cell)
if i < grid_size - 1 {
positions.push(current_pos);
}
}
positions
}
/// Initialize grid positions based on terminal dimensions.
/// Must be called before is_grid_position().
pub fn initialize(&mut self, width: usize, height: usize) {
self.col_positions = Self::calculate_grid_positions(width, self.size);
self.row_positions = Self::calculate_grid_positions(height, self.size);
}
/// Checks if a pixel coordinate lies on a grid line or intersection.
pub fn is_grid_position(&self, x: usize, y: usize, _width: usize, _height: usize) -> bool {
let on_vertical = self.col_positions.contains(&x);
let on_horizontal = self.row_positions.contains(&y);
match self.style {
GridStyle::Cross => {
// Full horizontal and vertical lines
on_vertical || on_horizontal
}
GridStyle::Dots => {
// Only intersections
on_vertical && on_horizontal
}
GridStyle::Gradient => {
// Same as cross, but opacity varies based on distance from center
on_vertical || on_horizontal
}
}
}
/// Returns (on_vertical, on_horizontal) for a given position
pub fn get_grid_lines(&self, x: usize, y: usize) -> (bool, bool) {
let on_vertical = self.col_positions.contains(&x);
let on_horizontal = self.row_positions.contains(&y);
(on_vertical, on_horizontal)
}
/// Calculates the effective opacity for a grid pixel.
///
/// Accounts for adaptive opacity (based on content brightness) and gradient style.
pub fn calculate_opacity(
&self,
x: usize,
y: usize,
width: usize,
height: usize,
avg_brightness: f32,
) -> f32 {
let mut opacity = self.opacity;
// Adaptive opacity - increase when trails are sparse
if self.adaptive && avg_brightness < 0.2 {
opacity *= 2.0;
}
// Gradient fade from center
if self.style == GridStyle::Gradient {
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let dx = (x as f32 - center_x).abs() / center_x;
let dy = (y as f32 - center_y).abs() / center_y;
let distance = (dx * dx + dy * dy).sqrt();
let fade = (1.0 - distance * 0.5).max(0.3);
opacity *= fade;
}
opacity.clamp(0.0, 1.0)
}
/// Blends the grid color with the underlying cell color.
pub fn blend_color(
&self,
grid_color: RgbColor,
cell_color: RgbColor,
opacity: f32,
) -> RgbColor {
// Blend: result = cell * (1 - opacity) + grid * opacity
RgbColor {
r: ((cell_color.r as f32 * (1.0 - opacity)) + (grid_color.r as f32 * opacity)) as u8,
g: ((cell_color.g as f32 * (1.0 - opacity)) + (grid_color.g as f32 * opacity)) as u8,
b: ((cell_color.b as f32 * (1.0 - opacity)) + (grid_color.b as f32 * opacity)) as u8,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_grid_style_from_str() {
assert_eq!(GridStyle::from_str("cross").unwrap(), GridStyle::Cross);
assert_eq!(GridStyle::from_str("dots").unwrap(), GridStyle::Dots);
assert_eq!(
GridStyle::from_str("gradient").unwrap(),
GridStyle::Gradient
);
assert_eq!(GridStyle::from_str("CROSS").unwrap(), GridStyle::Cross);
assert!(GridStyle::from_str("invalid").is_err());
}
#[test]
fn test_grid_positions_exact_division() {
// 30 rows, grid_size 10 → 3 per cell, lines at 3, 6, 9, 12, 15, 18, 21, 24, 27
let positions = GridRenderer::calculate_grid_positions(30, 10);
assert_eq!(positions.len(), 9);
assert_eq!(positions[0], 3);
assert_eq!(positions[1], 6);
assert_eq!(positions[2], 9);
// All spacing should be 3
for i in 1..positions.len() {
assert_eq!(positions[i] - positions[i - 1], 3);
}
}
#[test]
fn test_grid_positions_with_remainder() {
// 24 rows, grid_size 10 → base spacing 2, remainder 4
// Should distribute 4 extra spaces to center cells
// Cell heights: 2, 2, 2, 3, 3, 3, 3, 2, 2, 2
// Line positions: 2, 4, 6, 9, 12, 15, 18, 20, 22
let positions = GridRenderer::calculate_grid_positions(24, 10);
assert_eq!(positions.len(), 9);
// Verify the actual positions
assert_eq!(positions[0], 2); // After first cell (height 2)
assert_eq!(positions[1], 4); // After second cell (height 2)
assert_eq!(positions[2], 6); // After third cell (height 2)
assert_eq!(positions[3], 9); // After fourth cell (height 3) - center starts
assert_eq!(positions[4], 12); // After fifth cell (height 3)
assert_eq!(positions[5], 15); // After sixth cell (height 3)
assert_eq!(positions[6], 18); // After seventh cell (height 3) - center ends
assert_eq!(positions[7], 20); // After eighth cell (height 2)
assert_eq!(positions[8], 22); // After ninth cell (height 2)
}
#[test]
fn test_grid_positions_edge_cases() {
// Zero grid size
let positions = GridRenderer::calculate_grid_positions(100, 0);
assert_eq!(positions.len(), 0);
// Zero terminal size
let positions = GridRenderer::calculate_grid_positions(0, 10);
assert_eq!(positions.len(), 0);
// Grid size larger than terminal
let positions = GridRenderer::calculate_grid_positions(5, 10);
assert_eq!(positions.len(), 0);
// Grid size = 1 (single cell, no lines)
let positions = GridRenderer::calculate_grid_positions(100, 1);
assert_eq!(positions.len(), 0);
}
#[test]
fn test_grid_cross_position() {
let mut grid = GridRenderer::new(
GridStyle::Cross,
10,
RgbColor {
r: 255,
g: 255,
b: 255,
},
0.15,
false,
);
grid.initialize(100, 100);
// 100x100 grid with size 10 means spacing of 10
// Lines at: 10, 20, 30, 40, 50, 60, 70, 80, 90
assert!(grid.is_grid_position(10, 0, 100, 100)); // Vertical line
assert!(grid.is_grid_position(0, 10, 100, 100)); // Horizontal line
assert!(grid.is_grid_position(10, 10, 100, 100)); // Intersection
assert!(!grid.is_grid_position(5, 5, 100, 100)); // Not on grid
assert!(!grid.is_grid_position(0, 0, 100, 100)); // Corner is not a line
}
#[test]
fn test_grid_dots_position() {
let mut grid = GridRenderer::new(
GridStyle::Dots,
10,
RgbColor {
r: 255,
g: 255,
b: 255,
},
0.15,
false,
);
grid.initialize(100, 100);
// Dots only at intersections
assert!(grid.is_grid_position(10, 10, 100, 100)); // Intersection
assert!(!grid.is_grid_position(10, 0, 100, 100)); // Only vertical
assert!(!grid.is_grid_position(0, 10, 100, 100)); // Only horizontal
assert!(!grid.is_grid_position(5, 5, 100, 100)); // Not on grid
}
#[test]
fn test_adaptive_opacity() {
let grid = GridRenderer::new(
GridStyle::Cross,
10,
RgbColor {
r: 26,
g: 26,
b: 26,
},
0.15,
true,
);
// Low brightness should increase opacity
let low_opacity = grid.calculate_opacity(0, 0, 100, 100, 0.1);
assert!(low_opacity > 0.15);
// High brightness should keep normal opacity
let high_opacity = grid.calculate_opacity(0, 0, 100, 100, 0.5);
assert_eq!(high_opacity, 0.15);
}
#[test]
fn test_color_blending() {
let grid = GridRenderer::new(
GridStyle::Cross,
10,
RgbColor {
r: 26,
g: 26,
b: 26,
},
0.5,
false,
);
let grid_color = RgbColor {
r: 100,
g: 100,
b: 100,
};
let cell_color = RgbColor {
r: 200,
g: 200,
b: 200,
};
let blended = grid.blend_color(grid_color, cell_color, 0.5);
// Should be 50/50 blend
assert_eq!(blended.r, 150);
assert_eq!(blended.g, 150);
assert_eq!(blended.b, 150);
}
}