Skip to main content

cuqueclicker_lib/ui/
tree.rs

1//! Full-screen upgrade-tree modal renderer.
2//!
3//! Layout (top to bottom):
4//! - Header (3 rows): title, cuques, cursor coord, owned count.
5//! - Canvas (variable): the pannable infinite tree, centered on the cursor.
6//! - Info pane (8 rows): focused node's title + primitives + cost + action hints.
7//!
8//! Bottom `help_height` rows (the help bar `ui::mod.rs` draws first) are
9//! intentionally left untouched.
10//!
11//! Coordinate spaces:
12//! - `lot (lx, ly)`: integer coords on the infinite tree grid.
13//! - `canvas (cx, cy)`: cell coords on the same infinite grid where boxes
14//!   live (each lot occupies `LOT_W × LOT_H` cells).
15//! - `screen (sx, sy)`: actual terminal cell coords.
16//!
17//! Conversion: `screen = canvas - pan + canvas_origin`.
18
19use ratatui::{prelude::*, style::Modifier as StyleMod, widgets::*};
20
21use crate::format;
22use crate::game::powerup::PowerupKind;
23use crate::game::state::{
24    GameState, PURCHASE_FLASH_TICKS, TREE_REFUND_FRACTION, UNLOCK_FLASH_TICKS,
25};
26use crate::game::tree::coord::TreeCoord;
27use crate::game::tree::naming::primitive_blurb;
28use crate::game::tree::node::{self, LOT_H, LOT_W, Rarity};
29use crate::game::tree::primitive::{Op, Primitive, Target};
30use crate::i18n::t;
31use crate::input::TreeRenderState;
32use crate::ui::{TreeButtonAction, border, hud_title};
33
34/// Tree-modal renderer output. Carries the same per-node click rects the
35/// modal has always exposed, plus an optional clickable action button
36/// (Buy or Refund) for the focused node — that's the left-click target
37/// for touch / single-button players.
38pub struct TreeDrawOutput {
39    pub node_rects: Vec<(TreeCoord, Rect)>,
40    pub action_button: Option<(TreeButtonAction, Rect, TreeCoord)>,
41}
42
43/// Per-frame ease factor. The displayed pan moves `PAN_TWEEN_FACTOR` of the
44/// remaining distance to the target per render frame — small enough to
45/// feel smooth, big enough that a multi-lot jump arrives in well under a
46/// second at 60Hz.
47const PAN_TWEEN_FACTOR: f32 = 0.20;
48/// Snap threshold: once the pan is within this many cells of the target,
49/// jump exactly to the target so subpixel residuals don't blur the
50/// rendering forever.
51const PAN_SNAP_EPSILON: f32 = 0.5;
52
53/// How many lots (in each direction from the cursor) to consider for
54/// rendering. Conservative — anything outside this radius is fog regardless
55/// of edges.
56const VISIBLE_RADIUS_LOTS: i32 = 6;
57
58/// Help bar height reserved at the bottom — `ui::mod.rs` rendered the help
59/// text there before we got the frame, so we leave those rows alone.
60const HELP_BAR_HEIGHT: u16 = 2;
61
62const INFO_PANE_HEIGHT: u16 = 8;
63const HEADER_HEIGHT: u16 = 3;
64
65pub fn draw(
66    frame: &mut Frame,
67    area: Rect,
68    state: &GameState,
69    mouse_pos: Option<(u16, u16)>,
70    tree_render: &mut TreeRenderState,
71) -> TreeDrawOutput {
72    let modal_h = area.height.saturating_sub(HELP_BAR_HEIGHT);
73    if modal_h < HEADER_HEIGHT + INFO_PANE_HEIGHT + 4 {
74        // Terminal too small; bail out gracefully.
75        return TreeDrawOutput {
76            node_rects: Vec::new(),
77            action_button: None,
78        };
79    }
80    let modal = Rect {
81        x: area.x,
82        y: area.y,
83        width: area.width,
84        height: modal_h,
85    };
86
87    // Clear the modal area so the biscuit / sidebar / HUD beneath don't
88    // bleed through. We paint every cell with a space at the resting bg.
89    fill_area(frame, modal);
90
91    let rows = Layout::vertical([
92        Constraint::Length(HEADER_HEIGHT),
93        Constraint::Min(1),
94        Constraint::Length(INFO_PANE_HEIGHT),
95    ])
96    .split(modal);
97    let header_area = rows[0];
98    let canvas_area = rows[1];
99    let info_area = rows[2];
100
101    draw_header(frame, header_area, state);
102    let node_rects = draw_canvas(frame, canvas_area, state, mouse_pos, tree_render);
103    let action_button = draw_info_pane(frame, info_area, state);
104    // Hover-fill the info-pane action button (buy / refund) so the player
105    // reads it as a clickable target. Matches the help-bar token hover:
106    // white fg + dark bg tint + bold.
107    if let (Some((_, r, _)), Some((mx, my))) = (action_button, mouse_pos)
108        && mx >= r.x
109        && mx < r.x + r.width
110        && my >= r.y
111        && my < r.y + r.height
112    {
113        let buf = frame.buffer_mut();
114        for y in r.y..r.y + r.height {
115            for x in r.x..r.x + r.width {
116                if let Some(cell) = buf.cell_mut((x, y)) {
117                    cell.set_style(
118                        cell.style()
119                            .fg(Color::Rgb(255, 255, 255))
120                            .bg(Color::Rgb(40, 40, 50))
121                            .add_modifier(StyleMod::BOLD),
122                    );
123                }
124            }
125        }
126    }
127    TreeDrawOutput {
128        node_rects,
129        action_button,
130    }
131}
132
133fn fill_area(frame: &mut Frame, area: Rect) {
134    // Reset bg to terminal default so the player's $BG_COLOR shows through —
135    // the rest of the app (HUD title, sidebar, biscuit area) lets the
136    // terminal bg bleed through, and the tree modal should match. We still
137    // need to overpaint every cell with a space so the biscuit / sidebar /
138    // HUD text drawn before us is wiped.
139    let buf = frame.buffer_mut();
140    let clear = Style::default().fg(Color::Reset).bg(Color::Reset);
141    for y in area.y..area.y.saturating_add(area.height) {
142        for x in area.x..area.x.saturating_add(area.width) {
143            if let Some(cell) = buf.cell_mut((x, y)) {
144                cell.set_char(' ');
145                cell.set_style(clear);
146            }
147        }
148    }
149}
150
151// ---------- Header ----------------------------------------------------------
152
153fn draw_header(frame: &mut Frame, area: Rect, state: &GameState) {
154    // Casino-style animated border + the SAME title the main HUD shows.
155    // The "— Upgrade Tree" suffix lets the player tell at a glance which
156    // mode they're in while the chrome stays continuous with the rest of
157    // the app.
158    let lang = t();
159    let title = format!("{}—{}", hud_title(), lang.tree_title);
160    border::draw_animated(frame, area, state, &title);
161    if area.width < 3 || area.height < 3 {
162        return;
163    }
164    let inner = Rect {
165        x: area.x + 1,
166        y: area.y + 1,
167        width: area.width.saturating_sub(2),
168        height: area.height.saturating_sub(2),
169    };
170    let cursor = state.tree.cursor;
171    let line = Line::from(vec![
172        Span::raw(format!("{}: ", lang.hud_cuques)),
173        Span::styled(
174            format::big(state.displayed_cuques),
175            Style::default()
176                .fg(Color::Rgb(180, 255, 180))
177                .add_modifier(StyleMod::BOLD),
178        ),
179        Span::raw(format!("   {}: ", lang.hud_fps)),
180        Span::styled(
181            format::rate(state.displayed_fps),
182            Style::default().fg(Color::Rgb(200, 200, 240)),
183        ),
184        Span::styled(
185            format!("    cursor ({:+}, {:+})", cursor.x, cursor.y),
186            Style::default().fg(Color::Rgb(180, 200, 255)),
187        ),
188        Span::styled(
189            // Owned count excludes the anchor — it's an always-on cuque,
190            // not something the player "bought". Subtracting it makes the
191            // count read "buys made", which matches the player's mental
192            // model.
193            format!(
194                "    owned: {}",
195                state
196                    .tree
197                    .bought
198                    .iter()
199                    .filter(|c| **c != TreeCoord::ORIGIN)
200                    .count()
201            ),
202            Style::default().fg(Color::Rgb(220, 220, 180)),
203        ),
204    ]);
205    frame.render_widget(Paragraph::new(line), inner);
206}
207
208// ---------- Canvas ----------------------------------------------------------
209
210#[derive(Clone)]
211struct VisibleNode {
212    spec_box_x: i32,
213    spec_box_y: i32,
214    box_w: u16,
215    box_h: u16,
216    rarity: Rarity,
217    lot: TreeCoord,
218    dominant_target: Target,
219    cost: f64,
220    owned: bool,
221    reachable: bool,
222    affordable: bool,
223    is_anchor: bool,
224    title_short: String,
225    /// Active flash strengths in [0.0, 1.0]. Green = just bought; yellow =
226    /// just unlocked by another buy; red = just refunded.
227    buy_flash: f32,
228    unlock_flash: f32,
229    refund_flash: f32,
230}
231
232fn draw_canvas(
233    frame: &mut Frame,
234    area: Rect,
235    state: &GameState,
236    mouse_pos: Option<(u16, u16)>,
237    tree_render: &mut TreeRenderState,
238) -> Vec<(TreeCoord, Rect)> {
239    if area.width < 10 || area.height < 5 {
240        return Vec::new();
241    }
242
243    let cursor = state.tree.cursor;
244    let canvas_center_x = (area.width / 2) as i32;
245    let canvas_center_y = (area.height / 2) as i32;
246
247    // Target pan: keep cursor centered. Updated whenever the cursor moves
248    // — keyboard nav (TreeFocus), bookmark jumps, and node clicks all flow
249    // through `state.tree.cursor`. Drag DOESN'T re-center; it directly
250    // modified `pan_*` AND `target_pan_*` in the input router, so when
251    // dragging ends the camera stays where the player dragged it.
252    let cursor_centered_x = (cursor.x * LOT_W + LOT_W / 2 - canvas_center_x) as f32;
253    let cursor_centered_y = (cursor.y * LOT_H + LOT_H / 2 - canvas_center_y) as f32;
254
255    if !tree_render.initialized {
256        // First frame of this modal session: snap to target, don't tween.
257        tree_render.pan_x = cursor_centered_x;
258        tree_render.pan_y = cursor_centered_y;
259        tree_render.target_pan_x = cursor_centered_x;
260        tree_render.target_pan_y = cursor_centered_y;
261        tree_render.prev_cursor = cursor;
262        tree_render.initialized = true;
263    } else {
264        if cursor != tree_render.prev_cursor {
265            // Cursor moved (keyboard nav, bookmark jump, click-to-focus).
266            // Recenter the target; the displayed pan eases toward it.
267            tree_render.target_pan_x = cursor_centered_x;
268            tree_render.target_pan_y = cursor_centered_y;
269            tree_render.prev_cursor = cursor;
270        }
271        // Tween only when not actively dragging — drag updates pan_* and
272        // target_pan_* together so tweening would be a no-op anyway, but
273        // explicitly skipping clarifies intent.
274        if tree_render.drag_last.is_none() {
275            let dx = tree_render.target_pan_x - tree_render.pan_x;
276            let dy = tree_render.target_pan_y - tree_render.pan_y;
277            if dx.abs() < PAN_SNAP_EPSILON && dy.abs() < PAN_SNAP_EPSILON {
278                tree_render.pan_x = tree_render.target_pan_x;
279                tree_render.pan_y = tree_render.target_pan_y;
280            } else {
281                tree_render.pan_x += dx * PAN_TWEEN_FACTOR;
282                tree_render.pan_y += dy * PAN_TWEEN_FACTOR;
283            }
284        }
285    }
286
287    // Round to integer cells for actual rendering — subpixel positions
288    // would render fractionally and look blurry.
289    let pan_x = tree_render.pan_x.round() as i32;
290    let pan_y = tree_render.pan_y.round() as i32;
291
292    // Harvest is centered on the lot currently under the VIEWPORT center,
293    // not the cursor lot. Drag-panning moves the camera without moving the
294    // cursor; anchoring the harvest to the cursor would leave the
295    // dragged-into edge unpopulated until the player clicked something to
296    // re-focus. With viewport-anchored harvest the tree grows naturally
297    // ahead of the camera, matching the "infinite" expectation.
298    let view_center_canvas_x = pan_x + canvas_center_x;
299    let view_center_canvas_y = pan_y + canvas_center_y;
300    let view_lot_x = view_center_canvas_x.div_euclid(LOT_W);
301    let view_lot_y = view_center_canvas_y.div_euclid(LOT_H);
302    let visible_radius = VISIBLE_RADIUS_LOTS + 2;
303    let mut visible: Vec<VisibleNode> = Vec::new();
304    for dy in -visible_radius..=visible_radius {
305        for dx in -visible_radius..=visible_radius {
306            let lot = TreeCoord::new(view_lot_x + dx, view_lot_y + dy);
307            let Some(spec) = node::node_at(lot.x, lot.y) else {
308                continue;
309            };
310            let owned = state.tree.bought.contains(&lot);
311            // While a wavefront is still walking toward this lot, hold it
312            // in its "unreachable" look — the box only flips reachable
313            // once the path finishes energizing.
314            let reachable = !owned && state.tree_reachable(lot) && !state.tree_unlock_pending(lot);
315            let affordable = state.affordable_cuques() >= spec.cost;
316            let title_short = truncate(&spec.title, (spec.box_w as usize).saturating_sub(2));
317            let buy_flash = state
318                .tree_buy_flash
319                .get(&lot)
320                .copied()
321                .map(|t| t as f32 / PURCHASE_FLASH_TICKS as f32)
322                .unwrap_or(0.0);
323            let unlock_flash = state
324                .tree_unlock_flash
325                .get(&lot)
326                .copied()
327                .map(|t| t as f32 / UNLOCK_FLASH_TICKS as f32)
328                .unwrap_or(0.0);
329            let refund_flash = state
330                .tree_refund_flash
331                .get(&lot)
332                .copied()
333                .map(|t| t as f32 / PURCHASE_FLASH_TICKS as f32)
334                .unwrap_or(0.0);
335            visible.push(VisibleNode {
336                spec_box_x: spec.box_x,
337                spec_box_y: spec.box_y,
338                box_w: spec.box_w,
339                box_h: spec.box_h,
340                rarity: spec.rarity,
341                lot,
342                dominant_target: spec.dominant_target,
343                cost: spec.cost,
344                owned,
345                reachable,
346                affordable,
347                is_anchor: spec.is_anchor,
348                title_short,
349                buy_flash,
350                unlock_flash,
351                refund_flash,
352            });
353        }
354    }
355
356    // Edges: pairs of visible lots that have a procedural edge.
357    let edges: Vec<(TreeCoord, TreeCoord)> = {
358        let mut out = Vec::new();
359        for i in 0..visible.len() {
360            for j in (i + 1)..visible.len() {
361                let a = visible[i].lot;
362                let b = visible[j].lot;
363                if node::edge_exists(a, b) {
364                    out.push((a, b));
365                }
366            }
367        }
368        out
369    };
370
371    // Draw edges first so boxes overpaint their junctions. The path
372    // glyphs at line termini use HALF-HEAVY box-drawing chars
373    // (`╿ ╽ ╼ ╾`) — heavier on the side facing the endpoint box, which
374    // most monospace fonts render slightly thicker, closing the visual
375    // half-cell gap between a `│` line and the box's `─` border
376    // without needing T-junctions or compromising the box outline.
377    for (a, b) in &edges {
378        let av = visible.iter().find(|v| v.lot == *a).cloned();
379        let bv = visible.iter().find(|v| v.lot == *b).cloned();
380        if let (Some(av), Some(bv)) = (av, bv) {
381            draw_edge(frame, area, pan_x, pan_y, &av, &bv, state);
382        }
383    }
384
385    let mut click_rects: Vec<(TreeCoord, Rect)> = Vec::new();
386    for v in &visible {
387        if let Some(r) = draw_box(frame, area, pan_x, pan_y, v, cursor, state) {
388            click_rects.push((v.lot, r));
389        }
390    }
391
392    // Mouse hover: brighten the box under the cursor.
393    if let Some((mx, my)) = mouse_pos {
394        for &(_, r) in &click_rects {
395            if mx >= r.x && mx < r.x + r.width && my >= r.y && my < r.y + r.height {
396                hover_lift(frame, r);
397                break;
398            }
399        }
400    }
401
402    click_rects
403}
404
405/// Map a canvas-grid cell to a screen cell. Returns None if outside the
406/// drawable rect.
407fn canvas_to_screen(area: Rect, pan_x: i32, pan_y: i32, cx: i32, cy: i32) -> Option<(u16, u16)> {
408    let sx = cx - pan_x + area.x as i32;
409    let sy = cy - pan_y + area.y as i32;
410    if sx < area.x as i32
411        || sx >= (area.x as i32 + area.width as i32)
412        || sy < area.y as i32
413        || sy >= (area.y as i32 + area.height as i32)
414    {
415        return None;
416    }
417    Some((sx as u16, sy as u16))
418}
419
420#[allow(clippy::too_many_arguments)]
421fn draw_box(
422    frame: &mut Frame,
423    area: Rect,
424    pan_x: i32,
425    pan_y: i32,
426    v: &VisibleNode,
427    cursor: TreeCoord,
428    state: &GameState,
429) -> Option<Rect> {
430    // Anchor (the cuque-sprite at origin) renders as a small ass instead
431    // of a regular box. It's auto-owned, has no primitives, and is the
432    // seed point for the rest of the tree.
433    if v.is_anchor {
434        return draw_anchor(frame, area, pan_x, pan_y, v, cursor, state);
435    }
436    let buf = frame.buffer_mut();
437    let bx = v.spec_box_x;
438    let by = v.spec_box_y;
439    let bw = v.box_w as i32;
440    let bh = v.box_h as i32;
441
442    // Box drawing chars: pick line set + style by node state.
443    let (corners, h_char, v_char, base_style) = box_chars_for(v);
444
445    // Cursor-on-this-lot: bold + bright tint over the regular palette.
446    let is_focus = v.lot == cursor;
447    let box_style = if is_focus {
448        base_style
449            .add_modifier(StyleMod::BOLD)
450            .add_modifier(StyleMod::REVERSED)
451    } else {
452        base_style
453    };
454
455    // Top, bottom borders + sides.
456    let mut painted_any = false;
457    let mut min_sx = u16::MAX;
458    let mut min_sy = u16::MAX;
459    let mut max_sx = 0u16;
460    let mut max_sy = 0u16;
461
462    for row in 0..bh {
463        for col in 0..bw {
464            let cx = bx + col;
465            let cy = by + row;
466            let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy) else {
467                continue;
468            };
469            let ch = if row == 0 && col == 0 {
470                corners.0
471            } else if row == 0 && col == bw - 1 {
472                corners.1
473            } else if row == bh - 1 && col == 0 {
474                corners.2
475            } else if row == bh - 1 && col == bw - 1 {
476                corners.3
477            } else if row == 0 || row == bh - 1 {
478                h_char
479            } else if col == 0 || col == bw - 1 {
480                v_char
481            } else {
482                // Interior: render title + cost on appropriate rows. Leave
483                // anything we don't have content for as a space (overwriting
484                // edges or fill).
485                let interior_w = (bw - 2) as usize;
486                let r_in = row - 1;
487                if r_in == 0 && interior_w > 0 {
488                    // Title row.
489                    v.title_short.chars().nth((col - 1) as usize).unwrap_or(' ')
490                } else if r_in == (bh - 2) - 1 && interior_w > 4 {
491                    // Cost row near bottom-interior. Render "Cost: X" or
492                    // glyphs as needed.
493                    let cost_str = if v.owned {
494                        "[ owned ]".to_string()
495                    } else {
496                        format::big(v.cost).to_string()
497                    };
498                    cost_str.chars().nth((col - 1) as usize).unwrap_or(' ')
499                } else if r_in > 0
500                    && r_in < (bh - 2)
501                    && v.rarity != Rarity::Small
502                    && r_in == 1
503                    && interior_w > 4
504                {
505                    // Mid row for Notable/Keystone: a primitive summary count.
506                    let summary = primitive_summary(v);
507                    summary.chars().nth((col - 1) as usize).unwrap_or(' ')
508                } else {
509                    ' '
510                }
511            };
512
513            if let Some(cell) = buf.cell_mut((sx, sy)) {
514                cell.set_char(ch);
515                cell.set_style(box_style);
516            }
517            painted_any = true;
518            min_sx = min_sx.min(sx);
519            min_sy = min_sy.min(sy);
520            max_sx = max_sx.max(sx);
521            max_sy = max_sy.max(sy);
522        }
523    }
524
525    if !painted_any {
526        return None;
527    }
528    Some(Rect {
529        x: min_sx,
530        y: min_sy,
531        width: max_sx.saturating_sub(min_sx).saturating_add(1),
532        height: max_sy.saturating_sub(min_sy).saturating_add(1),
533    })
534}
535
536/// Paint the cuque-sprite anchor at the origin lot — borrows
537/// `BISCUIT_TINY` for visual continuity with the main game. The asshole
538/// cell gets an `O` overlay so the ass reads as a cuque, not a generic
539/// outline.
540///
541/// Unfocused: warm white-gold, no fanciness — the ass is the seed point,
542/// not a button to click. Focused: per-cell **plasma shimmer** instead
543/// of the usual REVERSED inversion (which made the gold cuque look
544/// black-on-yellow and read as broken). The shimmer cycles through the
545/// game's already-warm tints (gold → red → purple → back), driven by
546/// `state.steady_phase` so the speed matches the rest of the app.
547fn draw_anchor(
548    frame: &mut Frame,
549    area: Rect,
550    pan_x: i32,
551    pan_y: i32,
552    v: &VisibleNode,
553    cursor: TreeCoord,
554    state: &GameState,
555) -> Option<Rect> {
556    let rows = crate::ui::biscuit::BISCUIT_TINY;
557    let focal = crate::ui::biscuit::BISCUIT_TINY_FOCAL;
558    let is_focus = v.lot == cursor;
559    let phase = state.steady_phase;
560
561    // Warm white-gold base. Used directly when unfocused; replaced per-cell
562    // with a plasma shimmer when focused.
563    let base_style = Style::default()
564        .fg(Color::Rgb(255, 230, 180))
565        .add_modifier(StyleMod::BOLD);
566
567    let buf = frame.buffer_mut();
568    let mut min_sx = u16::MAX;
569    let mut min_sy = u16::MAX;
570    let mut max_sx = 0u16;
571    let mut max_sy = 0u16;
572    let mut painted_any = false;
573
574    for (row_idx, line) in rows.iter().enumerate() {
575        for (col_idx, ch) in line.chars().enumerate() {
576            // Skip pure-space cells outside the art outline — leaving
577            // them as the cleared modal bg makes the anchor blend
578            // naturally with whatever terminal bg the user runs.
579            let is_focal = col_idx as u16 == focal.0 && row_idx as u16 == focal.1;
580            let glyph = if is_focal { 'O' } else { ch };
581            if glyph == ' ' {
582                continue;
583            }
584            let cx = v.spec_box_x + col_idx as i32;
585            let cy = v.spec_box_y + row_idx as i32;
586            let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy) else {
587                continue;
588            };
589            let style = if is_focus {
590                Style::default()
591                    .fg(plasma_color(phase, col_idx as i32, row_idx as i32))
592                    .add_modifier(StyleMod::BOLD)
593            } else {
594                base_style
595            };
596            if let Some(cell) = buf.cell_mut((sx, sy)) {
597                cell.set_char(glyph);
598                cell.set_style(style);
599            }
600            painted_any = true;
601            min_sx = min_sx.min(sx);
602            min_sy = min_sy.min(sy);
603            max_sx = max_sx.max(sx);
604            max_sy = max_sy.max(sy);
605        }
606    }
607
608    if !painted_any {
609        return None;
610    }
611    Some(Rect {
612        x: min_sx,
613        y: min_sy,
614        width: max_sx.saturating_sub(min_sx).saturating_add(1),
615        height: max_sy.saturating_sub(min_sy).saturating_add(1),
616    })
617}
618
619/// Per-cell plasma shimmer for the focused-anchor effect. Sums a few sine
620/// waves into a `[0, 1]` parameter `n`, then interpolates that around a
621/// warm 3-keyframe color loop (gold → red-amber → purple → gold). Each
622/// cell offsets in space (col, row) so the pattern drifts diagonally
623/// across the cuque, and the `phase` time-shifts the whole field every
624/// tick.
625fn plasma_color(phase: u32, col: i32, row: i32) -> Color {
626    let t = phase as f32 * 0.06;
627    let cx = col as f32 * 0.45;
628    let cy = row as f32 * 0.85;
629    let v = (cx + t).sin() + (cy + t * 1.27).sin() + ((cx + cy) * 0.6 + t * 0.83).sin();
630    let n = ((v / 3.0) + 1.0) * 0.5; // map [-1, 1] → [0, 1]
631
632    // Three warm keyframes from the game's existing palette.
633    let keys: [(f32, f32, f32); 3] = [
634        (255.0, 215.0, 110.0), // Lucky-ish gold
635        (255.0, 110.0, 90.0),  // warm Frenzy red
636        (210.0, 130.0, 255.0), // Buff purple
637    ];
638    let pos = n * 3.0;
639    let idx = (pos.floor() as usize) % 3;
640    let frac = pos - pos.floor();
641    let a = keys[idx];
642    let b = keys[(idx + 1) % 3];
643    let r = a.0 + (b.0 - a.0) * frac;
644    let g = a.1 + (b.1 - a.1) * frac;
645    let bb = a.2 + (b.2 - a.2) * frac;
646    Color::Rgb(
647        r.clamp(60.0, 255.0) as u8,
648        g.clamp(60.0, 255.0) as u8,
649        bb.clamp(60.0, 255.0) as u8,
650    )
651}
652
653fn primitive_summary(v: &VisibleNode) -> String {
654    // Very short tag describing rarity + dominant target. Fits inside a
655    // Notable's middle row (~16 chars).
656    let prefix = match v.rarity {
657        Rarity::Small => "small",
658        Rarity::Notable => "notable",
659        Rarity::Keystone => "KEYSTONE",
660    };
661    let target = match v.dominant_target {
662        Target::Fingerer(i) => crate::i18n::t()
663            .fingerer_names
664            .get(i as usize)
665            .copied()
666            .unwrap_or("?")
667            .to_string(),
668        Target::AllFingerers => "all fingerers".to_string(),
669        Target::Click => "click".to_string(),
670        Target::PowerupSpawn(k) => format!("{} spawn", powerup_short(k)),
671        Target::PowerupReward(k) => format!("{} reward", powerup_short(k)),
672        Target::PowerupDuration(k) => format!("{} time", powerup_short(k)),
673        Target::Prestige => "prestige".to_string(),
674        Target::GreenCoinStrength => "GC pow".to_string(),
675    };
676    format!("{}: {}", prefix, target)
677}
678
679fn powerup_short(k: PowerupKind) -> &'static str {
680    match k {
681        PowerupKind::Lucky => "Lucky",
682        PowerupKind::Frenzy => "Frenzy",
683        PowerupKind::Buff => "Buff",
684        PowerupKind::GreenCoin => "GCoin",
685    }
686}
687
688/// Pick box-drawing characters and a color style for a node's render state.
689/// Visual hierarchy (only OWNED uses double-line; everything else is
690/// dotted, with brightness distinguishing the four states):
691///
692/// - **Owned**: double-line `╔═╗`, bright biome color, BOLD.
693/// - **Buyable** (reachable + affordable): dotted line `┌╌┐`, near-WHITE.
694/// - **Reachable but unaffordable**: dotted line, dim biome color.
695/// - **Unreachable**: dotted line, very dark grey.
696///
697/// Active flashes (just-bought / just-unlocked / just-refunded) blend the
698/// fg color toward a tint by the flash strength so a buy "pulses green",
699/// a newly-reachable neighbor "pulses yellow", and a refunded lot
700/// "pulses red".
701fn box_chars_for(v: &VisibleNode) -> ((char, char, char, char), char, char, Style) {
702    // (top-left, top-right, bottom-left, bottom-right)
703    let owned_corners: (char, char, char, char) = ('╔', '╗', '╚', '╝');
704    let dotted_corners: (char, char, char, char) = ('┌', '┐', '└', '┘');
705
706    let biome = biome_color(v.dominant_target, v.rarity);
707    // Slightly darker than before so the "ignored" state reads as
708    // background chrome against the user's terminal bg, not as a
709    // candidate to click on.
710    let unreachable_fg = Color::Rgb(60, 60, 70);
711    // Near-white but tinted very slightly toward the biome's hue so the
712    // user can still tell biomes apart at a glance among buyable nodes.
713    // Blend ~85% white + 15% biome.bright.
714    let buyable_fg = blend_to_white(biome.bright, 0.15);
715
716    let mut result = if v.owned {
717        (
718            owned_corners,
719            '═',
720            '║',
721            Style::default()
722                .fg(biome.bright)
723                .add_modifier(StyleMod::BOLD),
724        )
725    } else if v.reachable && v.affordable {
726        (
727            dotted_corners,
728            '╌',
729            '╎',
730            Style::default().fg(buyable_fg).add_modifier(StyleMod::BOLD),
731        )
732    } else if v.reachable {
733        // Reachable but unaffordable: dim biome — clearly a tree node
734        // (biome-colored) but not currently actionable.
735        (dotted_corners, '╌', '╎', Style::default().fg(biome.dim))
736    } else {
737        // Unreachable: very dark grey, dotted. Reads as background.
738        (
739            dotted_corners,
740            '╌',
741            '╎',
742            Style::default().fg(unreachable_fg),
743        )
744    };
745
746    // Flash overlays. Strength is in [0, 1]; blend base fg toward tint.
747    if v.buy_flash > 0.001 {
748        let tint = (40.0, 230.0, 80.0); // green
749        result.3 = Style::default()
750            .fg(blend_to(biome.bright, tint, v.buy_flash))
751            .add_modifier(StyleMod::BOLD);
752    } else if v.refund_flash > 0.001 {
753        let tint = (255.0, 80.0, 80.0); // red
754        let from = if v.owned {
755            biome.bright
756        } else {
757            unreachable_fg
758        };
759        result.3 = Style::default()
760            .fg(blend_to(from, tint, v.refund_flash))
761            .add_modifier(StyleMod::BOLD);
762    } else if v.unlock_flash > 0.001 {
763        let tint = (255.0, 230.0, 120.0); // gold
764        let from = if v.reachable { buyable_fg } else { biome.dim };
765        result.3 = Style::default()
766            .fg(blend_to(from, tint, v.unlock_flash))
767            .add_modifier(StyleMod::BOLD);
768    }
769    result
770}
771
772/// Blend a base color toward pure white by `t` in [0, 1]. Used to pick
773/// the "buyable" fg — near-white with a faint biome tint so different
774/// biomes are still distinguishable among the buyable set.
775fn blend_to_white(base: Color, biome_weight: f32) -> Color {
776    let (br, bg, bb) = color_rgb(base);
777    let mix = biome_weight.clamp(0.0, 1.0);
778    let r = 240.0 * (1.0 - mix) + br * mix;
779    let g = 240.0 * (1.0 - mix) + bg * mix;
780    let b = 240.0 * (1.0 - mix) + bb * mix;
781    Color::Rgb(
782        r.clamp(0.0, 255.0) as u8,
783        g.clamp(0.0, 255.0) as u8,
784        b.clamp(0.0, 255.0) as u8,
785    )
786}
787
788fn blend_to(base: Color, tint: (f32, f32, f32), t: f32) -> Color {
789    let (br, bg, bb) = color_rgb(base);
790    let mix = t.clamp(0.0, 1.0);
791    Color::Rgb(
792        (br + (tint.0 - br) * mix).clamp(0.0, 255.0) as u8,
793        (bg + (tint.1 - bg) * mix).clamp(0.0, 255.0) as u8,
794        (bb + (tint.2 - bb) * mix).clamp(0.0, 255.0) as u8,
795    )
796}
797
798fn color_rgb(c: Color) -> (f32, f32, f32) {
799    match c {
800        Color::Rgb(r, g, b) => (r as f32, g as f32, b as f32),
801        _ => (200.0, 200.0, 200.0),
802    }
803}
804
805struct BiomeColors {
806    bright: Color,
807    dim: Color,
808}
809
810/// Map a target (the node's "biome") to a color pair. Each fingerer index
811/// gets a hand-picked tier color; globals get neutral/special tints.
812fn biome_color(target: Target, rarity: Rarity) -> BiomeColors {
813    // Keystones override biome with a hot red/purple. Spotting them is
814    // the whole point.
815    if matches!(rarity, Rarity::Keystone) {
816        return BiomeColors {
817            bright: Color::Rgb(255, 80, 200),
818            dim: Color::Rgb(150, 50, 120),
819        };
820    }
821    match target {
822        Target::Fingerer(idx) => fingerer_biome(idx as usize),
823        Target::AllFingerers => BiomeColors {
824            bright: Color::Rgb(255, 215, 0),
825            dim: Color::Rgb(150, 130, 0),
826        },
827        Target::Click => BiomeColors {
828            bright: Color::Rgb(255, 130, 130),
829            dim: Color::Rgb(150, 80, 80),
830        },
831        Target::PowerupSpawn(_) | Target::PowerupReward(_) | Target::PowerupDuration(_) => {
832            BiomeColors {
833                bright: Color::Rgb(220, 140, 255),
834                dim: Color::Rgb(130, 90, 150),
835            }
836        }
837        Target::Prestige => BiomeColors {
838            bright: Color::Rgb(255, 200, 220),
839            dim: Color::Rgb(150, 110, 130),
840        },
841        Target::GreenCoinStrength => BiomeColors {
842            bright: Color::Rgb(120, 230, 140),
843            dim: Color::Rgb(60, 130, 80),
844        },
845    }
846}
847
848fn fingerer_biome(idx: usize) -> BiomeColors {
849    match idx {
850        0 => BiomeColors {
851            bright: Color::Rgb(255, 220, 120),
852            dim: Color::Rgb(160, 130, 70),
853        },
854        1 => BiomeColors {
855            bright: Color::Rgb(255, 180, 100),
856            dim: Color::Rgb(160, 100, 60),
857        },
858        2 => BiomeColors {
859            bright: Color::Rgb(180, 255, 230),
860            dim: Color::Rgb(90, 150, 130),
861        },
862        3 => BiomeColors {
863            bright: Color::Rgb(255, 150, 180),
864            dim: Color::Rgb(160, 80, 100),
865        },
866        4 => BiomeColors {
867            bright: Color::Rgb(180, 220, 255),
868            dim: Color::Rgb(90, 130, 160),
869        },
870        5 => BiomeColors {
871            bright: Color::Rgb(140, 230, 200),
872            dim: Color::Rgb(70, 130, 100),
873        },
874        6 => BiomeColors {
875            bright: Color::Rgb(200, 160, 255),
876            dim: Color::Rgb(110, 80, 160),
877        },
878        7 => BiomeColors {
879            bright: Color::Rgb(160, 200, 255),
880            dim: Color::Rgb(80, 120, 160),
881        },
882        8 => BiomeColors {
883            bright: Color::Rgb(255, 255, 200),
884            dim: Color::Rgb(160, 160, 110),
885        },
886        _ => BiomeColors {
887            bright: Color::Rgb(255, 255, 255),
888            dim: Color::Rgb(180, 180, 180),
889        },
890    }
891}
892
893/// Orthogonal L-routing between two boxes — one bend with a rounded
894/// corner glyph (`╭ ╮ ╰ ╯`). Looks far cleaner than diagonal Bresenham
895/// stair-step, which produced walls of `///\\\`. Path leaves the source
896/// box from the side facing the destination, runs a straight segment,
897/// turns once with a rounded corner, runs the second segment, and
898/// terminates at the destination box's edge. Cells inside either endpoint
899/// box are skipped — the box's own border owns those.
900fn draw_edge(
901    frame: &mut Frame,
902    area: Rect,
903    pan_x: i32,
904    pan_y: i32,
905    a: &VisibleNode,
906    b: &VisibleNode,
907    state: &GameState,
908) {
909    let a_owned = state.tree.bought.contains(&a.lot);
910    let b_owned = state.tree.bought.contains(&b.lot);
911    // Pre-energize dim style — what the line looked like when neither
912    // endpoint was owned. While the wave is in flight, cells AHEAD of
913    // the wavefront keep this dim grey so the snake's head reads as
914    // physically painting the line from grey to lit.
915    let dim_style = Style::default().fg(Color::Rgb(80, 80, 100));
916    // Lit / resting style — what the line settles into once the wave
917    // has passed (and what cells behind the wave hold). Matches the
918    // one-owned and both-owned resting palettes.
919    let lit_style = if a_owned && b_owned {
920        Style::default()
921            .fg(Color::Rgb(255, 220, 120))
922            .add_modifier(StyleMod::BOLD)
923    } else if a_owned || b_owned {
924        Style::default().fg(Color::Rgb(180, 180, 200))
925    } else {
926        dim_style
927    };
928
929    // `edge_path_cells` is canonical (lo→hi lot order), so the path
930    // shape is the same regardless of which is `a` vs `b` here. That
931    // guarantees the wave overlays the same staircase the grey base
932    // line was drawn on — without canonicalization the wave could
933    // bend along a Bresenham mirror image of the resting line.
934    let anim = state
935        .tree_edge_anims
936        .iter()
937        .find(|an| (an.from == a.lot && an.to == b.lot) || (an.from == b.lot && an.to == a.lot));
938    let path = node::edge_path_cells(a.lot, b.lot);
939    if path.is_empty() {
940        return;
941    }
942    // Wave anchor: which end of the canonical path is the anim's source
943    // (anim.from). `canonical_lo` is the lot at path[0].
944    let canonical_lo = if (a.lot.x, a.lot.y) <= (b.lot.x, b.lot.y) {
945        a.lot
946    } else {
947        b.lot
948    };
949    let wave_at_start = anim.map(|an| an.from == canonical_lo).unwrap_or(true);
950    // Source-side leading_inside (cells inside the source box at the
951    // wave's start end of the canonical path). Seeds the head past the
952    // in-box prefix so the visible wave starts at the FIRST visible
953    // cell on tick 0 — regardless of how much of the path lives inside
954    // the source's bounding rect.
955    let source_node: &VisibleNode = if let Some(an) = anim {
956        if an.from == a.lot { a } else { b }
957    } else {
958        a
959    };
960    let leading_inside = if wave_at_start {
961        node::count_leading_in_rect(
962            &path,
963            source_node.spec_box_x,
964            source_node.spec_box_y,
965            source_node.box_w,
966            source_node.box_h,
967        )
968    } else {
969        node::count_trailing_in_rect(
970            &path,
971            source_node.spec_box_x,
972            source_node.spec_box_y,
973            source_node.box_w,
974            source_node.box_h,
975        )
976    };
977    let head_path_index = anim
978        .map(|an| (leading_inside + an.visible_advance()).min(path.len().saturating_sub(1)))
979        .unwrap_or(0);
980
981    // Same opacity rule as before: regular boxes are opaque across their
982    // whole bounding rect, the anchor is only opaque on actually-painted
983    // sprite cells (so edges can meet its rounded outline).
984    let in_a = |x: i32, y: i32| opaque_for(a, x, y);
985    let in_b = |x: i32, y: i32| opaque_for(b, x, y);
986
987    let buf = frame.buffer_mut();
988    let path_len = path.len();
989    for i in 0..path_len {
990        let (cx, cy) = path[i];
991        if in_a(cx, cy) || in_b(cx, cy) {
992            continue;
993        }
994        // Animation styling. `dist_from_head` counts how many cells we
995        // are behind the wavefront in the anim's direction-of-travel:
996        // 0 is the head cell, 1+ is the trailing tail, negative means
997        // we're ahead of the wavefront. The path is canonical (lo→hi);
998        // the wave runs from path[0] outward when the anim's source is
999        // at canonical_lo, otherwise from path[end] inward.
1000        let dist_from_head: i32 = if anim.is_some() {
1001            if wave_at_start {
1002                (head_path_index as i32) - (i as i32)
1003            } else {
1004                (head_path_index as i32) - ((path_len - 1 - i) as i32)
1005            }
1006        } else {
1007            0
1008        };
1009
1010        let prev_raw = if i > 0 { Some(path[i - 1]) } else { None };
1011        let next_raw = path.get(i + 1).copied();
1012        let prev_in_box = prev_raw.is_some_and(|(px, py)| in_a(px, py) || in_b(px, py));
1013        let next_in_box = next_raw.is_some_and(|(nx, ny)| in_a(nx, ny) || in_b(nx, ny));
1014        let prev = prev_raw.filter(|_| !prev_in_box);
1015        let next = next_raw.filter(|_| !next_in_box);
1016        // dir_to_box: direction FROM this cell TOWARD the in-box
1017        // neighbor. Tells the glyph picker where the endpoint box is,
1018        // which can differ from the line's motion direction at this
1019        // cell (a Bresenham staircase can end with a horizontal step
1020        // into a box that's vertically above/below).
1021        let dir_to_box = if prev_in_box {
1022            prev_raw.map(|p| dir_between((cx, cy), p))
1023        } else if next_in_box {
1024            next_raw.map(|n| dir_between((cx, cy), n))
1025        } else {
1026            None
1027        };
1028        let glyph = path_glyph(
1029            prev.map(|p| dir_between(p, (cx, cy))),
1030            next.map(|n| dir_between((cx, cy), n)),
1031            dir_to_box,
1032        );
1033
1034        // Snake-game styling. The head cell is pure white; the few
1035        // cells just behind it pulse through cyan; further-behind
1036        // cells settle into the lit resting style (so the trail
1037        // STAYS lit — the snake grows). Cells AHEAD of the head are
1038        // still grey (pre-energize), so the wave reads as the snake
1039        // eating its way along the wire.
1040        let style = if anim.is_some() {
1041            if dist_from_head < 0 {
1042                dim_style
1043            } else {
1044                match dist_from_head {
1045                    0 => Style::default()
1046                        .fg(Color::Rgb(255, 255, 255))
1047                        .add_modifier(StyleMod::BOLD),
1048                    1 => Style::default()
1049                        .fg(Color::Rgb(120, 220, 255))
1050                        .add_modifier(StyleMod::BOLD),
1051                    2 => Style::default()
1052                        .fg(Color::Rgb(80, 170, 230))
1053                        .add_modifier(StyleMod::BOLD),
1054                    _ => lit_style,
1055                }
1056            }
1057        } else {
1058            lit_style
1059        };
1060
1061        if let Some((sx, sy)) = canvas_to_screen(area, pan_x, pan_y, cx, cy)
1062            && let Some(cell) = buf.cell_mut((sx, sy))
1063        {
1064            cell.set_char(glyph);
1065            cell.set_style(style);
1066        }
1067    }
1068    // Diagonal anchor metadata is no longer consulted at render time —
1069    // the staircase doesn't care which "L-corner" the procgen reserved.
1070    let _ = node::diagonal_route_via;
1071}
1072
1073#[derive(Copy, Clone, PartialEq, Eq, Debug)]
1074enum Dir {
1075    Up,
1076    Down,
1077    Left,
1078    Right,
1079}
1080
1081/// Returns `true` if the cell at canvas coord `(x, y)` is "opaque" for
1082/// edge-routing purposes — i.e. the renderer will paint a visible
1083/// glyph there, so an edge line should stop short and not intrude.
1084///
1085/// For regular boxes: every cell in the bounding rect is opaque (the
1086/// box paints title text, primitives, and border in every cell).
1087/// For the cuque anchor: every cell inside the SILHOUETTE is opaque —
1088/// the silhouette per row spans the leftmost to rightmost non-space
1089/// characters in `BISCUIT_TINY`. Cells inside the silhouette are part
1090/// of the visible cuque body (even where the source has interior
1091/// spaces, they're still inside the rounded outline). Cells in the
1092/// bounding rect but OUTSIDE the silhouette (the four corners) are
1093/// transparent so edges glide past the rounded shape cleanly.
1094fn opaque_for(v: &VisibleNode, x: i32, y: i32) -> bool {
1095    let local_col = x - v.spec_box_x;
1096    let local_row = y - v.spec_box_y;
1097    if local_col < 0 || local_row < 0 || local_col >= v.box_w as i32 || local_row >= v.box_h as i32
1098    {
1099        return false;
1100    }
1101    if !v.is_anchor {
1102        return true;
1103    }
1104    // Anchor: opaque inside the per-row silhouette of BISCUIT_TINY.
1105    let rows = crate::ui::biscuit::BISCUIT_TINY;
1106    let Some(line) = rows.get(local_row as usize) else {
1107        return false;
1108    };
1109    let chars: Vec<char> = line.chars().collect();
1110    let first = chars.iter().position(|c| *c != ' ');
1111    let last = chars.iter().rposition(|c| *c != ' ');
1112    match (first, last) {
1113        (Some(f), Some(l)) => local_col >= f as i32 && local_col <= l as i32,
1114        _ => false,
1115    }
1116}
1117
1118fn dir_between(from: (i32, i32), to: (i32, i32)) -> Dir {
1119    if to.0 > from.0 {
1120        Dir::Right
1121    } else if to.0 < from.0 {
1122        Dir::Left
1123    } else if to.1 > from.1 {
1124        Dir::Down
1125    } else {
1126        Dir::Up
1127    }
1128}
1129
1130/// Glyph for a single cell in a path given the direction of motion when
1131/// arriving (`dir_in`) and departing (`dir_out`), plus the direction
1132/// from this cell TOWARD an endpoint box if either neighbor is filtered
1133/// out by the box's opaque region (`dir_to_box`).
1134///
1135/// Terminus handling — the load-bearing case. When the path approaches
1136/// an endpoint box, the last visible cell's LINE motion (e.g. a
1137/// rightward staircase step) can differ from the BOX direction
1138/// (e.g. the box sits directly above). A plain `─` would dead-end
1139/// facing empty space; the correct render is a rounded corner `╯` that
1140/// bends from the line's prev direction into the box edge. When line
1141/// motion and box direction lie on the same axis, the cell is just an
1142/// ordinary straight `─`/`│`.
1143fn path_glyph(dir_in: Option<Dir>, dir_out: Option<Dir>, dir_to_box: Option<Dir>) -> char {
1144    use Dir::*;
1145    // Helper: pick the corner glyph for a cell that has paint reaching
1146    // TWO of its edges (the two directions passed). Order doesn't matter.
1147    fn corner(a: Dir, b: Dir) -> char {
1148        let mut ds = [a, b];
1149        ds.sort_by_key(|d| match d {
1150            Up => 0,
1151            Down => 1,
1152            Left => 2,
1153            Right => 3,
1154        });
1155        match (ds[0], ds[1]) {
1156            (Up, Left) => '╯',
1157            (Up, Right) => '╰',
1158            (Down, Left) => '╮',
1159            (Down, Right) => '╭',
1160            // Two-edge combos that are actually straight runs.
1161            (Up, Down) => '│',
1162            (Left, Right) => '─',
1163            _ => '·',
1164        }
1165    }
1166
1167    // Terminus case: this cell has exactly one of dir_in / dir_out set,
1168    // and we know which direction the box sits.
1169    if let Some(box_dir) = dir_to_box {
1170        let line_dir = dir_in.or(dir_out);
1171        match line_dir {
1172            // Same-axis case — line motion is colinear with box
1173            // direction (same OR opposite, e.g. line emerges from a
1174            // box to the left going right). Ordinary straight glyph;
1175            // no terminator decoration.
1176            Some(d) if d == box_dir || d == opposite(box_dir) => {
1177                return match box_dir {
1178                    Up | Down => '│',
1179                    Left | Right => '─',
1180                };
1181            }
1182            // Perpendicular: the cell bends from the line's incoming
1183            // edge into the box's edge. Corner glyph.
1184            //
1185            // LAST visible cell (`dir_in = Some`, `dir_out = None`):
1186            // line came IN from `-dir_in` (the opposite edge of the
1187            // dir_in motion). Cell's painted edges are `-dir_in` and
1188            // `box_dir`.
1189            //
1190            // FIRST visible cell (`dir_in = None`, `dir_out = Some`):
1191            // line goes OUT toward `dir_out`. Cell's painted edges are
1192            // `dir_out` and `box_dir`.
1193            Some(d) => {
1194                let line_edge = if dir_in.is_some() { opposite(d) } else { d };
1195                return corner(line_edge, box_dir);
1196            }
1197            None => {
1198                return match box_dir {
1199                    Up | Down => '│',
1200                    Left | Right => '─',
1201                };
1202            }
1203        }
1204    }
1205
1206    match (dir_in, dir_out) {
1207        // Straight runs through the middle of the line.
1208        (Some(Right), Some(Right)) | (Some(Left), Some(Left)) => '─',
1209        (Some(Down), Some(Down)) | (Some(Up), Some(Up)) => '│',
1210        // Rounded turns. The corner glyph connects the cell's two
1211        // OUTGOING edges (the directions the line leaves through):
1212        //   ╭ connects DOWN and RIGHT edges
1213        //   ╮ connects DOWN and LEFT
1214        //   ╰ connects UP and RIGHT
1215        //   ╯ connects UP and LEFT
1216        (Some(Right), Some(Down)) | (Some(Up), Some(Left)) => '╮',
1217        (Some(Right), Some(Up)) | (Some(Down), Some(Left)) => '╯',
1218        (Some(Left), Some(Down)) | (Some(Up), Some(Right)) => '╭',
1219        (Some(Left), Some(Up)) | (Some(Down), Some(Right)) => '╰',
1220        // U-turns (shouldn't happen in Bresenham staircase) and the
1221        // both-None case fall back to a neutral marker.
1222        _ => '·',
1223    }
1224}
1225
1226fn opposite(d: Dir) -> Dir {
1227    match d {
1228        Dir::Up => Dir::Down,
1229        Dir::Down => Dir::Up,
1230        Dir::Left => Dir::Right,
1231        Dir::Right => Dir::Left,
1232    }
1233}
1234
1235fn hover_lift(frame: &mut Frame, r: Rect) {
1236    let buf = frame.buffer_mut();
1237    for y in r.y..r.y + r.height {
1238        for x in r.x..r.x + r.width {
1239            if let Some(cell) = buf.cell_mut((x, y)) {
1240                cell.set_style(cell.style().add_modifier(StyleMod::BOLD));
1241            }
1242        }
1243    }
1244}
1245
1246// ---------- Info pane ------------------------------------------------------
1247
1248fn draw_info_pane(
1249    frame: &mut Frame,
1250    area: Rect,
1251    state: &GameState,
1252) -> Option<(TreeButtonAction, Rect, TreeCoord)> {
1253    let lang = t();
1254    // Localized button-label strings. Computed once per draw so the
1255    // click-rect math (which needs the rendered width) stays in lockstep
1256    // with the rendered text under either locale.
1257    let buy_button_label = lang.tree_buy_button;
1258    let refund_button_label = lang.tree_refund_button;
1259    let cursor = state.tree.cursor;
1260    let mut lines: Vec<Line> = Vec::new();
1261    // The rect of the currently-rendered action button (if any). We
1262    // compute it from string-prefix lengths during the line build and
1263    // return it for the input router to hit-test.
1264    let mut action_button: Option<(TreeButtonAction, Rect, TreeCoord)> = None;
1265    // Inner content lives one row below the top border that the
1266    // surrounding Block draws.
1267    let inner_y = area.y + 1;
1268    let inner_x = area.x;
1269
1270    match node::node_at(cursor.x, cursor.y) {
1271        None => {
1272            lines.push(Line::styled(
1273                lang.tree_empty_lot_fmt
1274                    .replacen("{:+}", &format!("{:+}", cursor.x), 1)
1275                    .replacen("{:+}", &format!("{:+}", cursor.y), 1),
1276                Style::default().fg(Color::Rgb(120, 120, 130)),
1277            ));
1278            lines.push(Line::raw(""));
1279            lines.push(Line::styled(
1280                lang.tree_empty_lot_hint,
1281                Style::default().fg(Color::Rgb(160, 160, 170)),
1282            ));
1283        }
1284        Some(spec) => {
1285            // The anchor gets its own info-pane copy — no rarity tag, no
1286            // primitives, no buy/refund options. It's the cuque itself,
1287            // always active.
1288            if spec.is_anchor {
1289                lines.push(Line::from(vec![
1290                    Span::styled(
1291                        format!("{}  ", spec.title),
1292                        Style::default()
1293                            .fg(Color::Rgb(255, 220, 130))
1294                            .add_modifier(StyleMod::BOLD),
1295                    ),
1296                    Span::styled(
1297                        lang.tree_anchor_tag,
1298                        Style::default()
1299                            .fg(Color::Rgb(255, 200, 100))
1300                            .add_modifier(StyleMod::BOLD),
1301                    ),
1302                ]));
1303                lines.push(Line::styled(
1304                    lang.tree_anchor_blurb,
1305                    Style::default().fg(Color::Rgb(200, 200, 210)),
1306                ));
1307                lines.push(Line::raw(""));
1308                lines.push(Line::styled(
1309                    lang.tree_anchor_footer,
1310                    Style::default().fg(Color::Rgb(160, 160, 170)),
1311                ));
1312                let p = Paragraph::new(lines).block(
1313                    Block::default()
1314                        .borders(Borders::TOP)
1315                        .border_style(Style::default().fg(Color::Rgb(60, 60, 80))),
1316                );
1317                frame.render_widget(p, area);
1318                return action_button;
1319            }
1320
1321            let owned = state.tree.bought.contains(&cursor);
1322            let reachable = state.tree_reachable(cursor);
1323            let affordable = state.affordable_cuques() >= spec.cost;
1324            let rarity_label = match spec.rarity {
1325                Rarity::Small => lang.tree_rarity_small,
1326                Rarity::Notable => lang.tree_rarity_notable,
1327                Rarity::Keystone => lang.tree_rarity_keystone,
1328            };
1329            let rarity_color = match spec.rarity {
1330                Rarity::Small => Color::Rgb(200, 200, 220),
1331                Rarity::Notable => Color::Rgb(255, 220, 120),
1332                Rarity::Keystone => Color::Rgb(255, 80, 200),
1333            };
1334            lines.push(Line::from(vec![
1335                Span::styled(
1336                    format!("{}  ", spec.title),
1337                    Style::default()
1338                        .fg(Color::Rgb(255, 255, 255))
1339                        .add_modifier(StyleMod::BOLD),
1340                ),
1341                Span::styled(
1342                    format!("[{}]", rarity_label),
1343                    Style::default()
1344                        .fg(rarity_color)
1345                        .add_modifier(StyleMod::BOLD),
1346                ),
1347            ]));
1348            for p in &spec.primitives {
1349                lines.push(Line::from(vec![
1350                    Span::raw("  • "),
1351                    Span::styled(primitive_blurb(*p), Style::default().fg(prim_color(*p))),
1352                ]));
1353            }
1354            lines.push(Line::raw(""));
1355            // The action hint is the LAST line we push to `lines`. Its
1356            // row in screen coords is `inner_y + lines.len()` *just
1357            // before* we push it. Capture that now and use it below
1358            // when computing the button rect.
1359            let action_row = inner_y + lines.len() as u16;
1360            let owned_tag_padded = format!("  {}  ", lang.tree_owned_tag);
1361            let cost_label_padded = format!("  {}", lang.tree_cost_label);
1362            let action_hint = if owned {
1363                let can_refund = state.can_refund_tree_node(cursor);
1364                if !can_refund {
1365                    let reason = if cursor == TreeCoord::ORIGIN {
1366                        lang.tree_refund_reason_origin
1367                    } else {
1368                        lang.tree_refund_reason_orphan
1369                    };
1370                    Line::from(vec![
1371                        Span::styled(
1372                            owned_tag_padded.clone(),
1373                            Style::default().fg(Color::Rgb(180, 220, 180)),
1374                        ),
1375                        Span::styled(
1376                            lang.tree_no_refund_fmt.replacen("{}", reason, 1),
1377                            Style::default().fg(Color::Rgb(170, 130, 130)),
1378                        ),
1379                    ])
1380                } else {
1381                    let refund = (spec.cost * TREE_REFUND_FRACTION).floor();
1382                    let loss = spec.cost - refund;
1383                    let label_len = refund_button_label.chars().count() as u16;
1384                    action_button = Some((
1385                        TreeButtonAction::Refund,
1386                        Rect {
1387                            x: inner_x + owned_tag_padded.chars().count() as u16,
1388                            y: action_row,
1389                            width: label_len,
1390                            height: 1,
1391                        },
1392                        cursor,
1393                    ));
1394                    Line::from(vec![
1395                        Span::styled(
1396                            owned_tag_padded.clone(),
1397                            Style::default().fg(Color::Rgb(180, 220, 180)),
1398                        ),
1399                        Span::styled(
1400                            refund_button_label,
1401                            Style::default()
1402                                .fg(Color::Rgb(220, 220, 120))
1403                                .add_modifier(StyleMod::BOLD)
1404                                .add_modifier(StyleMod::UNDERLINED),
1405                        ),
1406                        Span::styled(
1407                            format!(
1408                                "  {}",
1409                                lang.tree_refund_returns_fmt
1410                                    .replacen("{}", &format::big(refund), 1)
1411                                    .replacen("{}", &format::big(loss), 1)
1412                            ),
1413                            Style::default().fg(Color::Rgb(180, 150, 150)),
1414                        ),
1415                    ])
1416                }
1417            } else if !reachable {
1418                Line::styled(
1419                    format!("  {}", lang.tree_unreachable_hint),
1420                    Style::default().fg(Color::Rgb(180, 100, 100)),
1421                )
1422            } else if !affordable {
1423                let need_more = lang.tree_cost_need_more_fmt.replacen(
1424                    "{}",
1425                    &format::big(spec.cost - state.affordable_cuques()),
1426                    1,
1427                );
1428                Line::styled(
1429                    format!(
1430                        "{}{}  {}",
1431                        cost_label_padded,
1432                        format::big(spec.cost),
1433                        need_more
1434                    ),
1435                    Style::default().fg(Color::Rgb(220, 100, 100)),
1436                )
1437            } else {
1438                let cost_text = format::big(spec.cost);
1439                // Line layout: "{cost_label_padded}{cost}   {buy_button}"
1440                let prefix_cols = cost_label_padded.chars().count()
1441                    + cost_text.chars().count()
1442                    + "   ".chars().count();
1443                let label_len = buy_button_label.chars().count() as u16;
1444                action_button = Some((
1445                    TreeButtonAction::Buy,
1446                    Rect {
1447                        x: inner_x + prefix_cols as u16,
1448                        y: action_row,
1449                        width: label_len,
1450                        height: 1,
1451                    },
1452                    cursor,
1453                ));
1454                Line::from(vec![
1455                    Span::raw(cost_label_padded.clone()),
1456                    Span::styled(
1457                        cost_text,
1458                        Style::default()
1459                            .fg(Color::Rgb(120, 255, 120))
1460                            .add_modifier(StyleMod::BOLD),
1461                    ),
1462                    Span::raw("   "),
1463                    Span::styled(
1464                        buy_button_label,
1465                        Style::default()
1466                            .fg(Color::Rgb(220, 220, 120))
1467                            .add_modifier(StyleMod::BOLD)
1468                            .add_modifier(StyleMod::UNDERLINED),
1469                    ),
1470                ])
1471            };
1472            lines.push(action_hint);
1473        }
1474    }
1475
1476    let p = Paragraph::new(lines).block(
1477        Block::default()
1478            .borders(Borders::TOP)
1479            .border_style(Style::default().fg(Color::Rgb(60, 60, 80))),
1480    );
1481    frame.render_widget(p, area);
1482    action_button
1483}
1484
1485fn prim_color(p: Primitive) -> Color {
1486    if p.is_bane() {
1487        Color::Rgb(255, 130, 130)
1488    } else {
1489        match p.op {
1490            Op::AddPercent => Color::Rgb(180, 230, 255),
1491            Op::MulFactor => Color::Rgb(255, 220, 120),
1492            Op::FlatAdd => Color::Rgb(180, 255, 200),
1493            Op::CostMul => Color::Rgb(220, 180, 255),
1494            Op::SpawnRateMul | Op::EffectMul => Color::Rgb(255, 200, 240),
1495        }
1496    }
1497}
1498
1499fn truncate(s: &str, max: usize) -> String {
1500    if max == 0 {
1501        return String::new();
1502    }
1503    let count = s.chars().count();
1504    if count <= max {
1505        return s.to_string();
1506    }
1507    let take = max.saturating_sub(1).max(1);
1508    let mut out: String = s.chars().take(take).collect();
1509    out.push('…');
1510    out
1511}