warriors/erga/rs/app/src/widgets.rs

//! The parts the window is assembled from: meters that separate what erga
//! costs from what the machine was doing anyway, key/value rows, the panel
//! frame, the crystal, and the two numbers that carry the story.

use eframe::egui;
use egui::{Align2, Color32, FontId, Pos2, Sense, Stroke, Vec2};

use crate::theme::{caps, play_bold};
use crate::{BG, CREAM, MINT, MUTE};

/// A meter that separates *what erga costs* from what the machine was doing
/// anyway: the solid segment is the miner, the dim one behind it is
/// everything else. A bar showing only the total answers the wrong question โ€”
/// from it you cannot tell whether erga is the reason the machine is busy.
pub fn meter(ui: &mut egui::Ui, label: &str, mine: f32, total: f32, val: &str, tint: Color32) {
    ui.vertical(|ui| {
        caps(ui, label, 9.0, tint.gamma_multiply(0.9));
        ui.add_space(4.0);
        let w = ui.available_width();
        let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, 9.0), Sense::hover());
        ui.painter().rect_filled(rect, 4.5, Color32::from_rgb(24, 34, 29));
        let total = total.clamp(0.0, 1.0);
        let mine = mine.clamp(0.0, total);
        ui.painter().rect_filled(
            egui::Rect::from_min_size(rect.min, Vec2::new(w * total, 9.0)),
            4.5,
            tint.gamma_multiply(0.26),
        );
        if mine > 0.0005 {
            ui.painter().rect_filled(
                egui::Rect::from_min_size(rect.min, Vec2::new((w * mine).max(3.0), 9.0)),
                4.5,
                tint,
            );
        }
        for i in 1..8 {
            let x = rect.min.x + w * i as f32 / 8.0;
            ui.painter().line_segment(
                [Pos2::new(x, rect.min.y + 1.0), Pos2::new(x, rect.max.y - 1.0)],
                Stroke::new(1.0, BG),
            );
        }
        if resp.hovered() {
            resp.on_hover_text(format!(
                "erga {:.0}%  ยท  everything else {:.0}%",
                mine * 100.0,
                (total - mine).max(0.0) * 100.0
            ));
        }
        ui.add_space(4.0);
        let mut job = egui::text::LayoutJob::default();
        job.append(
            val,
            0.0,
            egui::TextFormat {
                font_id: play_bold(14.0),
                color: CREAM.gamma_multiply(0.92),
                ..Default::default()
            },
        );
        ui.label(job);
    });
}

/// A key/value row inside a card (no outer margins โ€” the card supplies them).
/// Like `card_row`, but the value wears a colour that means something.
pub fn card_row_tinted(ui: &mut egui::Ui, key: &str, val: &str, tint: Color32) {
    ui.horizontal(|ui| {
        ui.label(egui::RichText::new(key).color(MINT.gamma_multiply(0.85)).size(11.5));
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            ui.label(egui::RichText::new(val).color(tint).size(12.0).monospace());
        });
    });
}

pub fn card_row(ui: &mut egui::Ui, key: &str, val: &str) {
    ui.horizontal(|ui| {
        ui.label(egui::RichText::new(key).color(MINT.gamma_multiply(0.85)).size(11.0));
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            ui.label(egui::RichText::new(val).color(CREAM.gamma_multiply(0.88)).size(11.5).monospace());
        });
    });
}

/// A bordered panel. When `h` is given the content area is allocated at
/// exactly that size โ€” not merely *at least* it โ€” because a minimum only
/// stops a panel shrinking, and the one on the right grows from the bottom.
/// Two panels that must look like one object have to be told the same size,
/// A bordered panel. With a height given, the rectangle is allocated and
/// stroked directly and the content is drawn inside it, clipped. Asking a
/// layout for a size is a request; taking the rectangle is a guarantee โ€”
/// and two panels that read as one object must be the same size exactly,
/// A bordered panel. With a height given the content ui is *fixed* to it โ€”
/// `set_height`, not `set_min_height` โ€” which does two things at once: the
/// two panels end up identical, and `available_height` inside becomes a
/// finite number, so a bottom-anchored child can pin itself without any
/// spacer arithmetic to overshoot.
pub fn panel_frame(ui: &mut egui::Ui, w: f32, h: f32, add: impl FnOnce(&mut egui::Ui)) {
    egui::Frame::none()
        .stroke(Stroke::new(1.0, MINT.gamma_multiply(0.22)))
        .rounding(14.0)
        .inner_margin(egui::Margin::symmetric(18.0, 16.0))
        .show(ui, |ui| {
            ui.vertical(|ui| {
                ui.set_width(w - 36.0);
                if h > 0.0 {
                    ui.set_height(h - 32.0);
                }
                add(ui);
            });
        });
}

/// The heptagon sigil โ€” soft3's crystal, here as the one button. Fills
/// mint when mining, outline when idle; the inner facet counter-rotates.
pub fn draw_crystal(ui: &egui::Ui, c: Pos2, r: f32, on: bool, spin: f32, hover: bool) {
    let painter = ui.painter();
    let hept = |radius: f32, phase: f32| -> Vec<Pos2> {
        (0..7)
            .map(|i| {
                let a = phase - std::f32::consts::FRAC_PI_2
                    + i as f32 * std::f32::consts::TAU / 7.0;
                Pos2::new(c.x + radius * a.cos(), c.y + radius * a.sin())
            })
            .collect()
    };
    let outer = hept(r * if hover && !on { 1.05 } else { 1.0 }, 0.0);
    if on {
        // soft outer glow โ€” a few expanding rings with falling alpha
        for i in 1..=4 {
            let k = i as f32;
            painter.add(egui::Shape::closed_line(
                hept(r * (1.0 + k * 0.06), 0.0),
                Stroke::new(2.0, MINT.gamma_multiply(0.10 / k)),
            ));
        }
        painter.add(egui::Shape::convex_polygon(outer.clone(), MINT, Stroke::new(1.5, MINT)));
        let inner = hept(r * 0.62, -spin);
        painter.add(egui::Shape::closed_line(inner, Stroke::new(1.0, BG.gamma_multiply(0.6))));
    } else {
        for i in 1..=3 {
            let k = i as f32;
            painter.add(egui::Shape::closed_line(
                hept(r * (1.0 + k * 0.05), 0.0),
                Stroke::new(1.5, MINT.gamma_multiply(0.06 / k)),
            ));
        }
        painter.add(egui::Shape::closed_line(outer, Stroke::new(1.5, MINT.gamma_multiply(0.85))));
        let inner = hept(r * 0.62, spin);
        painter.add(egui::Shape::closed_line(inner, Stroke::new(1.0, MINT.gamma_multiply(0.35))));
    }
}

/// The balance, at the top of the window: the number everything else exists
/// to move. It is the one figure here that can grow without bound, so it is
/// also the one allowed to shrink its own type rather than spill.
pub fn big_balance(ui: &mut egui::Ui, on_chain: Option<f64>, w: f32) {
    let text = match on_chain {
        Some(e) => format!("{e:.4}"),
        None => "0.0000".to_string(),
    };
    // Always full strength. Dimming it for being zero made the hashrate the
    // brightest thing on screen, which inverts what this app is for.
    let colour = MINT;
    let avail = (w - 90.0).max(80.0);
    let mut size = 60.0f32;
    while size > 18.0 {
        let g = ui.painter().layout_no_wrap(text.clone(), play_bold(size), colour);
        if g.size().x <= avail {
            break;
        }
        size -= 2.0;
    }
    ui.vertical_centered(|ui| {
        // No label: a large number in ERG, at the head of a miner's window,
        // does not need to be told what it is.
        ui.horizontal(|ui| {
            let g = ui.painter().layout_no_wrap(text.clone(), play_bold(size), colour);
            let row = g.size().x + 46.0;
            ui.add_space(((ui.available_width() - row) / 2.0).max(0.0));
            let mut job = egui::text::LayoutJob::default();
            job.append(
                &text,
                0.0,
                egui::TextFormat { font_id: play_bold(size), color: colour, ..Default::default() },
            );
            ui.label(job);
            ui.add_space(7.0);
            caps(ui, "erg", 12.0, MUTE);
        });
    });
}


/// The invitation to start, at the top of the window. It breathes rather
/// than blinks: a slow sine on the alpha, so it draws the eye without
/// nagging it.
pub fn start_hint(ui: &mut egui::Ui) {
    let t = ui.input(|i| i.time);
    // ~7 s per breath, and never dimmer than two thirds: a hint that swings
    // to nearly-off reads as a warning light rather than as an invitation.
    let a = 0.66 + 0.34 * (0.5 - 0.5 * ((t * 0.9).cos() as f32));
    ui.vertical_centered(|ui| {
        caps(ui, "press the crystal to begin ยท or space", 10.5, MINT.gamma_multiply(a));
    });
}

pub fn human(n: u64) -> String {
    if n >= 1_000_000_000_000 {
        format!("{:.2} T", n as f64 / 1e12)
    } else if n >= 1_000_000_000 {
        format!("{:.2} B", n as f64 / 1e9)
    } else if n >= 1_000_000 {
        format!("{:.1} M", n as f64 / 1e6)
    } else {
        format!("{n}")
    }
}

/// The crystal as a button, with the readout inside it. Both layouts draw the
/// same object; only its size differs, so only its size is a parameter.
/// Returns true when it was pressed.
pub fn crystal_button(
    ui: &mut egui::Ui,
    w: f32,
    cr: f32,
    running: bool,
    spin: f32,
    press: f32,
    mhs: f64,
) -> bool {
    let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, cr * 2.0), Sense::click());
    draw_crystal(ui, rect.center(), cr * press, running, spin, resp.hovered());
    // Mining, the crystal *is* the readout: the rate lives where the label
    // was, because a filled crystal has already said "mining".
    let c = rect.center();
    if running {
        ui.painter().text(
            c - Vec2::new(0.0, cr * 0.06),
            Align2::CENTER_CENTER,
            format!("{mhs:.1}"),
            play_bold((cr * 0.42).clamp(34.0, 88.0)),
            BG,
        );
        ui.painter().text(
            c + Vec2::new(0.0, cr * 0.30),
            Align2::CENTER_CENTER,
            "MH/S",
            FontId::proportional((cr * 0.10).clamp(11.0, 18.0)),
            BG.gamma_multiply(0.75),
        );
    } else {
        ui.painter().text(
            c,
            Align2::CENTER_CENTER,
            "START",
            FontId::proportional((cr * 0.19).clamp(22.0, 38.0)),
            MINT,
        );
    }
    if resp.hovered() {
        ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
    }
    resp.clicked()
}

/// A battery: how much of the epoch table is built.
///
/// The fraction is measured, not timed โ€” the build is dispatched in pieces so
/// there is real progress to draw. `glow` (0..1) rides the pulse the caller
/// sets, so the battery and the words beside it breathe together instead of
/// each on their own clock.
pub fn battery(ui: &mut egui::Ui, frac: f32, glow: f32) {
    const W: f32 = 30.0;
    const H: f32 = 13.0;
    let (rect, _) = ui.allocate_exact_size(Vec2::new(W + 4.0, H), Sense::hover());
    let body = egui::Rect::from_min_size(rect.min, Vec2::new(W, H));
    let lit = MINT.gamma_multiply(0.45 + 0.55 * glow);

    // the case, and the nub that makes it read as a battery rather than a bar
    ui.painter().rect_filled(body, 3.0, Color32::from_rgb(18, 26, 22));
    ui.painter().rect_stroke(body, 3.0, Stroke::new(1.0, lit.gamma_multiply(0.75)));
    ui.painter().rect_filled(
        egui::Rect::from_min_size(
            Pos2::new(body.max.x + 1.0, body.center().y - 3.0),
            Vec2::new(3.0, 6.0),
        ),
        1.0,
        lit.gamma_multiply(0.75),
    );

    // the charge
    let f = frac.clamp(0.0, 1.0);
    if f > 0.001 {
        let inner = body.shrink(2.5);
        ui.painter().rect_filled(
            egui::Rect::from_min_size(inner.min, Vec2::new(inner.width() * f, inner.height())),
            1.5,
            lit,
        );
    }
    // cells, so it fills in steps the eye can count
    for i in 1..4 {
        let x = body.min.x + W * i as f32 / 4.0;
        ui.painter().line_segment(
            [Pos2::new(x, body.min.y + 2.5), Pos2::new(x, body.max.y - 2.5)],
            Stroke::new(1.0, BG),
        );
    }
}

Graph