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