nebulus 0.1.27

Low-latency native OpenIPC FPV ground station built with egui
use eframe::egui;

use crate::{app::NebulusApp, model::ReceiverState, ui::format_bitrate};

pub(crate) fn show(app: &mut NebulusApp, ui: &mut egui::Ui) {
    let available = ui.available_size();
    let (rect, response) = ui.allocate_exact_size(available, egui::Sense::click());
    let painter = ui.painter_at(rect);
    painter.rect_filled(rect, 0.0, egui::Color32::from_rgb(3, 7, 8));

    if let Some(frame_size) = app.frame_size {
        let source_aspect = frame_size[0] as f32 / frame_size[1].max(1) as f32;
        let target_aspect = rect.width() / rect.height().max(1.0);
        let size = if target_aspect > source_aspect {
            egui::vec2(rect.height() * source_aspect, rect.height())
        } else {
            egui::vec2(rect.width(), rect.width() / source_aspect)
        };
        let image_rect = egui::Rect::from_center_size(rect.center(), size);
        if let Some(renderer) = app.video_renderer.as_ref() {
            renderer.paint(&painter, image_rect);
        } else if let Some(texture) = app.texture.as_ref() {
            painter.image(
                texture.id(),
                image_rect,
                egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
                egui::Color32::WHITE,
            );
        }
    } else {
        let (title, detail) = match app.state {
            ReceiverState::Receiving => (
                "Waiting for an IDR frame",
                "Packets are arriving; waiting for codec configuration and a keyframe",
            ),
            ReceiverState::Connecting => (
                "Initializing receiver",
                "Configuring the USB adapter and radio",
            ),
            ReceiverState::Failed => (
                "Receiver error",
                "Open Diagnostics or Logs for the failure details",
            ),
            _ => (
                "Receiver not started",
                "Select an adapter, confirm the key, then start RX",
            ),
        };
        painter.text(
            rect.center() - egui::vec2(0.0, 12.0),
            egui::Align2::CENTER_CENTER,
            title,
            egui::FontId::proportional(22.0),
            egui::Color32::from_gray(190),
        );
        painter.text(
            rect.center() + egui::vec2(0.0, 18.0),
            egui::Align2::CENTER_CENTER,
            detail,
            egui::FontId::proportional(13.0),
            egui::Color32::from_gray(110),
        );
    }

    if app.settings.show_osd && app.state == ReceiverState::Receiving {
        let bar = egui::Rect::from_min_max(
            egui::pos2(rect.left(), rect.bottom() - 44.0),
            rect.right_bottom(),
        );
        painter.rect_filled(bar, 0.0, egui::Color32::from_black_alpha(205));
        painter.line_segment(
            [bar.left_top(), bar.right_top()],
            egui::Stroke::new(1.0, egui::Color32::from_rgb(61, 214, 154)),
        );
        let resolution = app
            .metrics
            .resolution
            .map(|[width, height]| format!("{width}x{height}"))
            .unwrap_or_else(|| "--".to_owned());
        let mut x = bar.left() + 13.0;
        let y = bar.center().y;
        if rect.width() < 620.0 {
            let compact_resolution = app
                .metrics
                .resolution
                .map(|[_, height]| format!("{height}p"))
                .unwrap_or_else(|| "--".to_owned());
            let strongest_rssi = app.metrics.rssi[0].max(app.metrics.rssi[1]);
            let strongest_link = app.metrics.link_score[0].max(app.metrics.link_score[1]);
            x = hud_item(&painter, x, y, HudIcon::Display, &compact_resolution, 48.0);
            x = hud_item(
                &painter,
                x,
                y,
                HudIcon::Fps,
                &format!("{:.0}", app.metrics.decode_fps),
                36.0,
            );
            x = hud_item(
                &painter,
                x,
                y,
                HudIcon::Bitrate,
                &format_compact_bitrate(app.metrics.bitrate_bps),
                48.0,
            );
            x = hud_item(
                &painter,
                x,
                y,
                HudIcon::Latency,
                &format!("{:.0}ms", app.metrics.decode_latency_ms),
                48.0,
            );
            x = hud_item(
                &painter,
                x,
                y,
                HudIcon::Signal,
                &strongest_rssi.to_string(),
                38.0,
            );
            x = hud_item(
                &painter,
                x,
                y,
                HudIcon::Loss,
                &app.metrics.lost_packets.to_string(),
                38.0,
            );
            let _ = hud_item(
                &painter,
                x,
                y,
                HudIcon::Link,
                &strongest_link.to_string(),
                38.0,
            );
        } else {
            x = hud_item(&painter, x, y, HudIcon::Display, &resolution, 82.0);
            x = hud_item(
                &painter,
                x,
                y,
                HudIcon::Fps,
                &format!("{:.0} fps", app.metrics.decode_fps),
                72.0,
            );
            x = hud_item(
                &painter,
                x,
                y,
                HudIcon::Bitrate,
                &format_bitrate(app.metrics.bitrate_bps),
                96.0,
            );
            x = hud_item(
                &painter,
                x,
                y,
                HudIcon::Latency,
                &format!("{:.1} ms", app.metrics.decode_latency_ms),
                78.0,
            );
            x = hud_item(
                &painter,
                x,
                y,
                HudIcon::Signal,
                &format!("{}/{} dBm", app.metrics.rssi[0], app.metrics.rssi[1]),
                96.0,
            );
            x = hud_item(
                &painter,
                x,
                y,
                HudIcon::Loss,
                &format!("{} lost", app.metrics.lost_packets),
                78.0,
            );
            let _ = hud_item(
                &painter,
                x,
                y,
                HudIcon::Link,
                &format!(
                    "{}/{}",
                    app.metrics.link_score[0], app.metrics.link_score[1]
                ),
                72.0,
            );
        }
    }

    if app.recording.state != crate::model::RecordingState::Idle {
        painter.text(
            rect.left_top() + egui::vec2(14.0, 14.0),
            egui::Align2::LEFT_TOP,
            if app.recording.state == crate::model::RecordingState::Armed {
                "REC ARMED - waiting for keyframe"
            } else {
                "REC"
            },
            egui::FontId::monospace(12.0),
            egui::Color32::from_rgb(244, 88, 96),
        );
    }

    let fullscreen_rect = egui::Rect::from_min_size(
        rect.right_bottom() - egui::vec2(42.0, 42.0),
        egui::vec2(36.0, 36.0),
    );
    let fullscreen = ui
        .put(
            fullscreen_rect,
            egui::Button::new("")
                .selected(app.video_fullscreen)
                .corner_radius(4),
        )
        .on_hover_text(if app.video_fullscreen {
            "Exit fullscreen"
        } else {
            "Enter fullscreen"
        });
    draw_fullscreen_icon(ui, &fullscreen, app.video_fullscreen);
    if fullscreen.clicked() {
        app.set_video_fullscreen(ui.ctx(), !app.video_fullscreen);
    }

    if response.double_clicked() {
        app.set_video_fullscreen(ui.ctx(), !app.video_fullscreen);
    }
}

#[derive(Clone, Copy)]
enum HudIcon {
    Display,
    Fps,
    Bitrate,
    Latency,
    Signal,
    Loss,
    Link,
}

fn hud_item(
    painter: &egui::Painter,
    x: f32,
    y: f32,
    icon: HudIcon,
    value: &str,
    slot_width: f32,
) -> f32 {
    let color = egui::Color32::from_gray(218);
    let galley = painter.layout_no_wrap(value.to_owned(), egui::FontId::monospace(10.0), color);
    let icon_rect = egui::Rect::from_center_size(egui::pos2(x + 6.0, y), egui::vec2(12.0, 12.0));
    draw_hud_icon(painter, icon_rect, icon, egui::Stroke::new(1.3, color));
    let text_x = x + 17.0;
    painter.galley(
        egui::pos2(text_x, y - galley.size().y * 0.5),
        galley.clone(),
        color,
    );
    x + slot_width
}

fn format_compact_bitrate(bits_per_second: f64) -> String {
    if bits_per_second >= 1_000_000.0 {
        format!("{:.1}M", bits_per_second / 1_000_000.0)
    } else if bits_per_second >= 1_000.0 {
        format!("{:.0}k", bits_per_second / 1_000.0)
    } else {
        format!("{bits_per_second:.0}")
    }
}

fn draw_hud_icon(painter: &egui::Painter, rect: egui::Rect, icon: HudIcon, stroke: egui::Stroke) {
    match icon {
        HudIcon::Display => {
            painter.rect_stroke(rect.shrink(1.0), 1.0, stroke, egui::StrokeKind::Middle);
            painter.line_segment(
                [
                    egui::pos2(rect.center().x, rect.bottom() - 1.0),
                    egui::pos2(rect.center().x, rect.bottom() + 1.5),
                ],
                stroke,
            );
        }
        HudIcon::Fps => {
            painter.add(egui::Shape::convex_polygon(
                vec![rect.left_top(), rect.left_bottom(), rect.right_center()],
                stroke.color,
                egui::Stroke::NONE,
            ));
        }
        HudIcon::Bitrate => {
            painter.arrow(rect.left_center(), egui::vec2(9.0, -4.0), stroke);
            painter.arrow(rect.right_center(), egui::vec2(-9.0, 4.0), stroke);
        }
        HudIcon::Latency => {
            painter.circle_stroke(rect.center(), 5.0, stroke);
            painter.line_segment(
                [rect.center(), rect.center() + egui::vec2(0.0, -3.0)],
                stroke,
            );
            painter.line_segment(
                [rect.center(), rect.center() + egui::vec2(2.5, 1.5)],
                stroke,
            );
        }
        HudIcon::Signal => {
            for (index, height) in [3.0, 5.0, 8.0, 11.0].into_iter().enumerate() {
                let x = rect.left() + index as f32 * 3.2;
                painter.line_segment(
                    [
                        egui::pos2(x, rect.bottom()),
                        egui::pos2(x, rect.bottom() - height),
                    ],
                    stroke,
                );
            }
        }
        HudIcon::Loss => {
            painter.add(egui::Shape::closed_line(
                vec![rect.center_top(), rect.left_bottom(), rect.right_bottom()],
                stroke,
            ));
            painter.line_segment(
                [
                    rect.center() - egui::vec2(0.0, 2.0),
                    rect.center() + egui::vec2(0.0, 1.0),
                ],
                stroke,
            );
            painter.circle_filled(
                rect.center_bottom() - egui::vec2(0.0, 1.5),
                0.8,
                stroke.color,
            );
        }
        HudIcon::Link => {
            painter.circle_stroke(rect.left_center() + egui::vec2(2.0, 0.0), 3.0, stroke);
            painter.circle_stroke(rect.right_center() - egui::vec2(2.0, 0.0), 3.0, stroke);
            painter.line_segment(
                [
                    rect.left_center() + egui::vec2(5.0, 0.0),
                    rect.right_center() - egui::vec2(5.0, 0.0),
                ],
                stroke,
            );
        }
    }
}

fn draw_fullscreen_icon(ui: &egui::Ui, response: &egui::Response, active: bool) {
    let color = if response.hovered() {
        ui.visuals().strong_text_color()
    } else {
        ui.visuals().text_color()
    };
    let stroke = egui::Stroke::new(1.8, color);
    let outer = response.rect.shrink(10.0);
    let arm = 5.0;
    let painter = ui.painter();
    if active {
        let inner = outer.shrink(3.0);
        for (corner, horizontal, vertical) in [
            (inner.left_top(), -arm, -arm),
            (inner.right_top(), arm, -arm),
            (inner.left_bottom(), -arm, arm),
            (inner.right_bottom(), arm, arm),
        ] {
            painter.line_segment([corner, corner + egui::vec2(horizontal, 0.0)], stroke);
            painter.line_segment([corner, corner + egui::vec2(0.0, vertical)], stroke);
        }
    } else {
        for (corner, horizontal, vertical) in [
            (outer.left_top(), arm, arm),
            (outer.right_top(), -arm, arm),
            (outer.left_bottom(), arm, -arm),
            (outer.right_bottom(), -arm, -arm),
        ] {
            painter.line_segment([corner, corner + egui::vec2(horizontal, 0.0)], stroke);
            painter.line_segment([corner, corner + egui::vec2(0.0, vertical)], stroke);
        }
    }
}