dais-ui 0.1.1

UI layer for Dais — egui-based presenter and audience windows
Documentation
//! Audience window overlays — laser, ink, spotlight.
//!
//! Renders visual aids over the audience slide image.

use dais_core::state::{PointerStyle, PresentationState};
use dais_document::typst_renderer::TextBoxRenderCache;

use crate::widgets::TextBoxTextureCache;

#[derive(Clone, Copy)]
pub struct OverlayGeometry {
    pub viewport_rect: egui::Rect,
    pub image_rect: egui::Rect,
    pub zoom_region: Option<((f32, f32), f32)>,
}

/// Draw all active overlays on the audience window.
pub fn draw_overlays(
    ui: &mut egui::Ui,
    geometry: OverlayGeometry,
    state: &PresentationState,
    tb_cache: &mut TextBoxRenderCache,
    texture_cache: &mut TextBoxTextureCache,
    draw_text_boxes: bool,
) {
    let overlay_rect = geometry.zoom_region.map_or(geometry.image_rect, |(center, factor)| {
        zoomed_overlay_rect(geometry.image_rect, center, factor)
    });
    let mut overlay_ui = ui.new_child(egui::UiBuilder::new().max_rect(geometry.viewport_rect));
    overlay_ui.set_clip_rect(geometry.image_rect);

    // Whiteboard is a separate persistent context, not an overlay on the active slide.
    if state.whiteboard_active {
        ui.painter().rect_filled(geometry.viewport_rect, 0.0, egui::Color32::WHITE);
        if !state.whiteboard_strokes.is_empty() {
            crate::widgets::draw_ink_strokes(ui, geometry.image_rect, &state.whiteboard_strokes);
        }
        return;
    }

    // Ink strokes
    let page_ink = state.current_page_ink();
    if !page_ink.is_empty() {
        crate::widgets::draw_ink_strokes(&mut overlay_ui, overlay_rect, page_ink);
    }

    // Text boxes (non-interactive on audience display)
    let page_text_boxes = state.current_page_text_boxes();
    if draw_text_boxes && !page_text_boxes.is_empty() {
        let _ = crate::widgets::draw_text_boxes(
            &mut overlay_ui,
            page_text_boxes,
            None,
            None,
            false,
            overlay_rect,
            tb_cache,
            texture_cache,
        );
    }

    // Laser pointer
    if state.laser_active
        && let Some((px, py)) = state.pointer_position
    {
        let appearance = state.current_pointer_appearance();
        draw_laser_overlay(
            &mut overlay_ui,
            overlay_rect,
            px,
            py,
            appearance.color,
            appearance.size,
            state.pointer_style,
        );
    }

    // Spotlight
    if state.spotlight_active
        && let Some((sx, sy)) = state.spotlight_position
    {
        draw_spotlight_overlay(
            &mut overlay_ui,
            overlay_rect,
            sx,
            sy,
            state.spotlight_radius,
            state.spotlight_dim_opacity,
        );
    }

    // Blackout
    if state.blacked_out {
        ui.painter().rect_filled(geometry.viewport_rect, 0.0, egui::Color32::BLACK);
    }
}

/// Return the screen-space rect that maps full-slide normalized coordinates onto
/// the currently displayed zoom crop.
pub fn zoomed_overlay_rect(image_rect: egui::Rect, center: (f32, f32), factor: f32) -> egui::Rect {
    let factor = factor.max(1.0);
    let half_u = 1.0 / (factor * 2.0);
    let half_v = 1.0 / (factor * 2.0);
    let u_center = center.0.clamp(half_u, 1.0 - half_u);
    let v_center = center.1.clamp(half_v, 1.0 - half_v);
    let uv_min = egui::pos2(u_center - half_u, v_center - half_v);
    let uv_size = egui::vec2(half_u * 2.0, half_v * 2.0);
    let overlay_size = egui::vec2(image_rect.width() / uv_size.x, image_rect.height() / uv_size.y);
    let overlay_min =
        image_rect.min - egui::vec2(uv_min.x * overlay_size.x, uv_min.y * overlay_size.y);

    egui::Rect::from_min_size(overlay_min, overlay_size)
}

/// Draw the configured pointer overlay at normalized position.
pub fn draw_laser_overlay(
    ui: &mut egui::Ui,
    image_rect: egui::Rect,
    nx: f32,
    ny: f32,
    color: [u8; 4],
    size: f32,
    style: PointerStyle,
) {
    let pos = denormalize(image_rect, nx, ny);
    let painter = ui.painter();
    let color = color32_from_rgba(color);
    let size = size.clamp(2.0, 96.0);
    let glow = egui::Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 72);

    match style {
        PointerStyle::Dot => {
            painter.circle_filled(pos, (size * 0.95).max(4.0), glow);
            painter.circle_filled(pos, (size * 0.45).max(2.0), color);
        }
        PointerStyle::Minimal => {
            painter.circle_filled(pos, (size * 0.195).max(2.1), color);
        }
        PointerStyle::Ring => {
            let stroke = egui::Stroke::new((size * 0.14).max(1.8), color);
            painter.circle_filled(pos, (size * 0.85).max(5.0), glow);
            painter.circle_stroke(pos, (size * 0.55).max(4.0), stroke);
        }
        PointerStyle::Bullseye => {
            let stroke = egui::Stroke::new((size * 0.13).max(1.6), color);
            painter.circle_filled(pos, (size * 0.9).max(5.0), glow);
            painter.circle_stroke(pos, (size * 0.6).max(4.0), stroke);
            painter.circle_filled(pos, (size * 0.13).max(1.4), color);
        }
        PointerStyle::Highlight => {
            let fill = egui::Color32::from_rgba_unmultiplied(
                color.r(),
                color.g(),
                color.b(),
                color.a().min(96),
            );
            let stroke = egui::Stroke::new(
                (size * 0.08).max(1.2),
                egui::Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 180),
            );
            painter.circle_filled(pos, (size * 0.75).max(5.0), fill);
            painter.circle_stroke(pos, (size * 0.75).max(5.0), stroke);
        }
        PointerStyle::Crosshair => {
            let arm = (size * 1.2).max(8.0);
            let gap = (size * 0.35).max(3.0);
            let stroke = egui::Stroke::new((size * 0.16).max(1.5), color);
            painter.circle_stroke(pos, (size * 0.45).max(3.0), stroke);
            painter
                .line_segment([pos + egui::vec2(-arm, 0.0), pos + egui::vec2(-gap, 0.0)], stroke);
            painter.line_segment([pos + egui::vec2(gap, 0.0), pos + egui::vec2(arm, 0.0)], stroke);
            painter
                .line_segment([pos + egui::vec2(0.0, -arm), pos + egui::vec2(0.0, -gap)], stroke);
            painter.line_segment([pos + egui::vec2(0.0, gap), pos + egui::vec2(0.0, arm)], stroke);
            painter.circle_filled(pos, (size * 0.18).max(1.5), color);
        }
        PointerStyle::Arrow => {
            // A conventional pointer arrow: tip at the cursor, trailing down-right
            // with a slight downward rotation from the 45-degree diagonal.
            let head_len = (size * 0.70).max(6.0);
            let head_half_width = (size * 0.24).max(2.2);
            let tail_len = (size * 0.31).max(3.0);
            let tail_start = head_len * 0.50;
            let angle = 60.0_f32.to_radians();
            let dir = egui::vec2(angle.cos(), angle.sin());
            let perp = egui::vec2(-dir.y, dir.x);
            let base_center = pos + dir * head_len;
            let left = base_center + perp * head_half_width;
            let right = base_center - perp * head_half_width;
            let tail_join = pos + dir * tail_start;
            let tail_end = pos + dir * (head_len + tail_len);
            let stroke = egui::Stroke::new((size * 0.145).max(1.7), color);
            painter.line_segment([tail_join, tail_end], stroke);
            painter.add(egui::Shape::convex_polygon(
                vec![pos, left, right],
                color,
                egui::Stroke::NONE,
            ));
            painter.circle_filled(pos, (size * 0.16).max(1.5), glow);
        }
    }
}

/// Draw a spotlight overlay — dims everything outside a circle.
///
/// Public so it can be shared between audience and presenter windows.
pub fn draw_spotlight_overlay(
    ui: &mut egui::Ui,
    image_rect: egui::Rect,
    nx: f32,
    ny: f32,
    radius: f32,
    dim_opacity: f32,
) {
    let half_size = radius.clamp(16.0, image_rect.width().min(image_rect.height()) * 0.45);
    let center = denormalize(image_rect, nx, ny);
    let painter = ui.painter_at(image_rect);
    let dim_color =
        egui::Color32::from_rgba_unmultiplied(0, 0, 0, dim_opacity_to_alpha(dim_opacity));
    let hole_rect =
        egui::Rect::from_center_size(center, egui::vec2(half_size * 2.0, half_size * 2.0))
            .intersect(image_rect);

    if hole_rect.top() > image_rect.top() {
        painter.rect_filled(
            egui::Rect::from_min_max(
                image_rect.left_top(),
                egui::pos2(image_rect.right(), hole_rect.top()),
            ),
            0.0,
            dim_color,
        );
    }
    if hole_rect.bottom() < image_rect.bottom() {
        painter.rect_filled(
            egui::Rect::from_min_max(
                egui::pos2(image_rect.left(), hole_rect.bottom()),
                image_rect.right_bottom(),
            ),
            0.0,
            dim_color,
        );
    }
    if hole_rect.left() > image_rect.left() {
        painter.rect_filled(
            egui::Rect::from_min_max(
                egui::pos2(image_rect.left(), hole_rect.top()),
                egui::pos2(hole_rect.left(), hole_rect.bottom()),
            ),
            0.0,
            dim_color,
        );
    }
    if hole_rect.right() < image_rect.right() {
        painter.rect_filled(
            egui::Rect::from_min_max(
                egui::pos2(hole_rect.right(), hole_rect.top()),
                egui::pos2(image_rect.right(), hole_rect.bottom()),
            ),
            0.0,
            dim_color,
        );
    }

    // Bright border to indicate spotlight edge
    painter.rect_stroke(
        hole_rect,
        0.0,
        egui::Stroke::new(2.0, egui::Color32::from_rgba_unmultiplied(255, 255, 255, 100)),
        egui::StrokeKind::Outside,
    );
}

fn color32_from_rgba(color: [u8; 4]) -> egui::Color32 {
    egui::Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3])
}

#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn dim_opacity_to_alpha(dim_opacity: f32) -> u8 {
    (dim_opacity.clamp(0.0, 1.0) * 255.0).round() as u8
}

/// Draw a zoom indicator at the given position.
///
/// Public so the presenter view can show the target region while the audience
/// sees only the zoomed result.
pub fn draw_zoom_indicator(
    ui: &mut egui::Ui,
    image_rect: egui::Rect,
    center: (f32, f32),
    factor: f32,
) {
    let pos = denormalize(image_rect, center.0, center.1);
    let painter = ui.painter();

    // Draw a rectangle showing the zoom region
    let half_w = image_rect.width() / (factor * 2.0);
    let half_h = image_rect.height() / (factor * 2.0);
    let zoom_rect = egui::Rect::from_center_size(pos, egui::vec2(half_w * 2.0, half_h * 2.0));

    painter.rect_stroke(
        zoom_rect,
        0.0,
        egui::Stroke::new(2.0, egui::Color32::YELLOW),
        egui::StrokeKind::Outside,
    );

    // Label
    painter.text(
        zoom_rect.right_top() + egui::vec2(4.0, 0.0),
        egui::Align2::LEFT_TOP,
        format!("{factor:.1}x"),
        egui::FontId::proportional(12.0),
        egui::Color32::YELLOW,
    );
}

/// Convert normalized (0..1) coordinates to screen-space within the image rect.
fn denormalize(rect: egui::Rect, nx: f32, ny: f32) -> egui::Pos2 {
    egui::pos2(rect.min.x + nx * rect.width(), rect.min.y + ny * rect.height())
}

#[cfg(test)]
mod tests {
    use super::zoomed_overlay_rect;

    #[test]
    fn zoomed_overlay_rect_maps_zoom_center_to_image_center() {
        let image_rect =
            egui::Rect::from_min_size(egui::pos2(10.0, 20.0), egui::vec2(400.0, 300.0));
        let overlay_rect = zoomed_overlay_rect(image_rect, (0.25, 0.5), 2.0);
        let mapped_center = egui::pos2(
            overlay_rect.left() + 0.25 * overlay_rect.width(),
            overlay_rect.top() + 0.5 * overlay_rect.height(),
        );

        assert!((mapped_center.x - image_rect.center().x).abs() < f32::EPSILON);
        assert!((mapped_center.y - image_rect.center().y).abs() < f32::EPSILON);
    }

    #[test]
    fn zoomed_overlay_rect_scales_by_zoom_factor() {
        let image_rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 300.0));
        let overlay_rect = zoomed_overlay_rect(image_rect, (0.5, 0.5), 2.5);

        assert!((overlay_rect.width() - 1000.0).abs() < f32::EPSILON);
        assert!((overlay_rect.height() - 750.0).abs() < f32::EPSILON);
    }
}