dream-ini 0.1.0

Import Morrowind.ini settings into OpenMW configuration files
Documentation
// SPDX-License-Identifier: GPL-3.0-only

#![cfg_attr(
    all(feature = "portmaster-gui", not(feature = "gui")),
    allow(dead_code)
)]

use super::form_nav::FormAdjustment;
use super::form_state::{GuiImportError, GuiImportResult};
use super::localization::{Localizer, UiText};
use std::ops::Range;

const GENERATED_CFG_ROW_OVERSCAN: usize = 2;
const GENERATED_CFG_COLUMN_GAP: f32 = 8.0;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) enum ResultPanel {
    Errors,
    Warnings,
    Events,
    #[default]
    GeneratedCfg,
}

pub(super) const fn default_result_panel(result: &GuiImportResult) -> ResultPanel {
    match result {
        GuiImportResult::Success { .. } => ResultPanel::GeneratedCfg,
        GuiImportResult::Error { .. } => ResultPanel::Errors,
    }
}

pub(super) fn result_tab(
    ui: &mut egui::Ui,
    selected: &mut ResultPanel,
    panel: ResultPanel,
    label: &str,
) {
    if ui.selectable_label(*selected == panel, label).clicked() {
        *selected = panel;
    }
}

pub(super) fn cycled_result_panel(panel: ResultPanel, adjustment: FormAdjustment) -> ResultPanel {
    let items = [
        ResultPanel::Errors,
        ResultPanel::Warnings,
        ResultPanel::Events,
        ResultPanel::GeneratedCfg,
    ];
    let Some(index) = items.iter().position(|item| *item == panel) else {
        return panel;
    };
    let next_index = match adjustment {
        FormAdjustment::Previous if index == 0 => items.len() - 1,
        FormAdjustment::Previous => index - 1,
        FormAdjustment::Next if index + 1 == items.len() => 0,
        FormAdjustment::Next => index + 1,
    };
    items.get(next_index).copied().unwrap_or(panel)
}

#[derive(Debug, Default)]
pub(super) struct GeneratedCfgPreviewCache {
    line_ranges: Vec<Range<usize>>,
    line_numbers: Vec<String>,
    source_ptr: usize,
    source_len: usize,
    number_width: usize,
    max_line_chars: usize,
}

impl GeneratedCfgPreviewCache {
    pub(super) fn clear(&mut self) {
        self.line_ranges.clear();
        self.line_numbers.clear();
        self.source_ptr = 0;
        self.source_len = 0;
        self.number_width = 0;
        self.max_line_chars = 0;
    }

    fn update(&mut self, cfg_text: &str) {
        let source_ptr = cfg_text.as_ptr() as usize;
        if self.source_ptr == source_ptr && self.source_len == cfg_text.len() {
            return;
        }

        self.source_ptr = source_ptr;
        self.source_len = cfg_text.len();
        self.line_ranges = cfg_line_ranges(cfg_text);
        self.number_width = number_width(self.line_ranges.len());
        self.line_numbers = padded_line_numbers(self.line_ranges.len(), self.number_width);
        self.max_line_chars = self
            .line_ranges
            .iter()
            .map(|range| cfg_text[range.clone()].chars().count())
            .max()
            .unwrap_or(0);
    }

    fn line_count(&self) -> usize {
        self.line_ranges.len()
    }
}

pub(super) fn show_error_panel(ui: &mut egui::Ui, localizer: Localizer, result: &GuiImportResult) {
    match result {
        GuiImportResult::Success { .. } => {
            ui.label(localizer.text(UiText::NoErrors));
        }
        GuiImportResult::Error { error } => {
            ui.colored_label(egui::Color32::RED, error_title(localizer, error));
        }
    }
}

pub(super) fn show_warning_panel(
    ui: &mut egui::Ui,
    localizer: Localizer,
    result: &GuiImportResult,
) {
    let GuiImportResult::Success { warnings, .. } = result else {
        ui.label(localizer.text(UiText::NoWarnings));
        return;
    };
    if warnings.is_empty() {
        ui.label(localizer.text(UiText::NoWarnings));
        return;
    }
    egui::ScrollArea::vertical().show(ui, |ui| {
        for warning in warnings {
            ui.label(localizer.warning_title(warning));
        }
    });
}

pub(super) fn show_event_panel(ui: &mut egui::Ui, localizer: Localizer, result: &GuiImportResult) {
    let GuiImportResult::Success { events, .. } = result else {
        ui.label(localizer.text(UiText::NoEvents));
        return;
    };
    if events.is_empty() {
        ui.label(localizer.text(UiText::NoEvents));
        return;
    }
    egui::ScrollArea::vertical().show(ui, |ui| {
        for event in events {
            ui.label(localizer.event_title(event));
        }
    });
}

pub(super) fn show_generated_cfg_panel(
    ui: &mut egui::Ui,
    localizer: Localizer,
    result: &mut GuiImportResult,
    cache: &mut GeneratedCfgPreviewCache,
    controller_scroll_delta: egui::Vec2,
) {
    let GuiImportResult::Success { cfg_text, .. } = result else {
        cache.clear();
        ui.label(localizer.text(UiText::NoGeneratedCfg));
        return;
    };
    cache.update(cfg_text);
    ui.scope(|ui| {
        ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
        egui::ScrollArea::both()
            .auto_shrink([false, false])
            .show_viewport(ui, |ui, viewport| {
                if controller_scroll_delta != egui::Vec2::ZERO {
                    ui.scroll_with_delta(controller_scroll_delta);
                }
                show_numbered_cfg(ui, cfg_text, cache, viewport);
            });
    });
}

fn show_numbered_cfg(
    ui: &mut egui::Ui,
    cfg_text: &str,
    cache: &GeneratedCfgPreviewCache,
    viewport: egui::Rect,
) {
    let monospace_font = egui::TextStyle::Monospace.resolve(ui.style());
    let (row_height, char_width) = ui.fonts_mut(|fonts| {
        (
            fonts.row_height(&monospace_font),
            fonts.glyph_width(&monospace_font, 'm'),
        )
    });
    let gutter_width = usize_to_f32(cache.number_width) * char_width;
    let body_width = usize_to_f32(cache.max_line_chars) * char_width;
    let content_size = egui::vec2(
        gutter_width + GENERATED_CFG_COLUMN_GAP + body_width,
        virtual_content_height(cache.line_count(), row_height),
    );
    let (_content_id, content_rect) = ui.allocate_space(content_size);

    let visible_rows = visible_row_range(
        viewport.top()..viewport.bottom(),
        row_height,
        cache.line_count(),
        GENERATED_CFG_ROW_OVERSCAN,
    );
    let content_top = content_rect.top();
    let content_left = content_rect.left();
    let body_left = content_left + gutter_width + GENERATED_CFG_COLUMN_GAP;

    ui.scope(|ui| {
        ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace);
        for row in visible_rows {
            let y = content_top + usize_to_f32(row) * row_height;
            let line = &cfg_text[cache.line_ranges[row].clone()];
            let number_rect = egui::Rect::from_min_size(
                egui::pos2(content_left, y),
                egui::vec2(gutter_width, row_height),
            );
            let body_rect = egui::Rect::from_min_size(
                egui::pos2(body_left, y),
                egui::vec2(body_width, row_height),
            );
            place_cfg_label(
                ui,
                number_rect,
                &cache.line_numbers[row],
                egui::Align::RIGHT,
            );
            place_cfg_label(ui, body_rect, line, egui::Align::LEFT);
        }
    });
}

fn place_cfg_label(ui: &mut egui::Ui, rect: egui::Rect, text: &str, align: egui::Align) {
    let mut child = ui.new_child(
        egui::UiBuilder::new()
            .max_rect(rect)
            .layout(egui::Layout::top_down(align)),
    );
    child.add(
        egui::Label::new(text)
            .selectable(false)
            .wrap_mode(egui::TextWrapMode::Extend),
    );
}

fn cfg_line_ranges(cfg_text: &str) -> Vec<Range<usize>> {
    let mut ranges = Vec::new();
    let mut start = 0;
    for line in cfg_text.split('\n') {
        let end = start + line.len();
        ranges.push(start..end);
        start = end + 1;
    }
    ranges
}

fn number_width(line_count: usize) -> usize {
    line_count.max(1).to_string().len()
}

fn padded_line_numbers(line_count: usize, number_width: usize) -> Vec<String> {
    (1..=line_count)
        .map(|line_number| format!("{line_number:>number_width$}"))
        .collect()
}

fn visible_row_range(
    viewport: Range<f32>,
    row_height: f32,
    line_count: usize,
    overscan: usize,
) -> Range<usize> {
    if line_count == 0 || row_height <= 0.0 {
        return 0..0;
    }
    let first = row_index_floor(viewport.start, row_height, line_count);
    let last = row_index_ceil(viewport.end, row_height, line_count);
    first.saturating_sub(overscan)..last.saturating_add(overscan).min(line_count)
}

fn virtual_content_height(line_count: usize, row_height: f32) -> f32 {
    usize_to_f32(line_count) * row_height
}

fn row_index_floor(offset: f32, row_height: f32, line_count: usize) -> usize {
    if offset <= 0.0 {
        return 0;
    }
    let mut low = 0;
    let mut high = line_count;
    while low < high {
        let mid = low + (high - low).div_ceil(2);
        if usize_to_f32(mid) * row_height <= offset {
            low = mid;
        } else {
            high = mid - 1;
        }
    }
    low
}

fn row_index_ceil(offset: f32, row_height: f32, line_count: usize) -> usize {
    if offset <= 0.0 {
        return 0;
    }
    let mut low = 0;
    let mut high = line_count;
    while low < high {
        let mid = low + (high - low) / 2;
        if usize_to_f32(mid) * row_height < offset {
            low = mid + 1;
        } else {
            high = mid;
        }
    }
    low
}

fn usize_to_f32(value: usize) -> f32 {
    const CHUNK: u16 = u16::MAX;
    let chunk = usize::from(CHUNK);
    let chunks = value / chunk;
    let remainder = value % chunk;
    let remainder = u16::try_from(remainder).expect("remainder must fit in u16");
    if chunks == 0 {
        return f32::from(remainder);
    }
    f32::from(CHUNK) * usize_to_f32(chunks) + f32::from(remainder)
}

fn error_title(localizer: Localizer, error: &GuiImportError) -> String {
    match error {
        GuiImportError::MissingMorrowindIni => localizer
            .text(UiText::SelectMorrowindIniBeforeImporting)
            .to_owned(),
        GuiImportError::MissingOutputPath => localizer
            .text(UiText::SelectOutputPathBeforeImporting)
            .to_owned(),
        GuiImportError::MissingExistingCfgForUpdate => localizer
            .text(UiText::SelectExistingCfgBeforeUpdating)
            .to_owned(),
        GuiImportError::Import(error) => localizer.error_title(error),
    }
}

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

    #[test]
    fn cfg_line_ranges_match_split_newline() {
        assert_eq!(cfg_line_ranges(""), vec![0..0]);
        assert_eq!(cfg_line_ranges("a"), vec![0..1]);
        assert_eq!(cfg_line_ranges("a\n"), vec![0..1, 2..2]);
        assert_eq!(cfg_line_ranges("a\n\n"), vec![0..1, 2..2, 3..3]);
        assert_eq!(cfg_line_ranges("a\nb"), vec![0..1, 2..3]);
    }

    #[test]
    fn padded_line_numbers_use_final_line_count_width() {
        assert_eq!(number_width(1), 1);
        assert_eq!(padded_line_numbers(1, number_width(1)), vec!["1"]);
        assert_eq!(number_width(9), 1);
        assert_eq!(padded_line_numbers(9, number_width(9))[8], "9");
        assert_eq!(number_width(10), 2);
        assert_eq!(
            &padded_line_numbers(10, number_width(10))[0..2],
            [" 1", " 2"]
        );
        assert_eq!(number_width(100), 3);
        assert_eq!(
            &padded_line_numbers(100, number_width(100))[0..3],
            ["  1", "  2", "  3"]
        );
    }

    #[test]
    fn visible_row_range_handles_fractional_scroll_and_bottom_edge() {
        assert_eq!(visible_row_range(0.0..20.0, 10.0, 10, 0), 0..2);
        assert_eq!(visible_row_range(5.5..25.5, 10.0, 10, 0), 0..3);
        assert_eq!(visible_row_range(95.0..100.0, 10.0, 10, 0), 9..10);
        assert_eq!(visible_row_range(25.0..45.0, 10.0, 10, 2), 0..7);
        assert_eq!(visible_row_range(85.0..105.0, 10.0, 10, 2), 6..10);
    }

    #[test]
    fn visible_row_range_uses_content_relative_viewport() {
        assert_eq!(visible_row_range(30.0..50.0, 10.0, 10, 0), 3..5);
    }

    #[test]
    fn virtual_content_height_is_line_count_times_row_height() {
        assert!((virtual_content_height(0, 12.0) - 0.0).abs() < f32::EPSILON);
        assert!((virtual_content_height(3, 12.5) - 37.5).abs() < f32::EPSILON);
    }
}