alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Application startup.

use crate::{
    adapters::bevy::{
        ui::{BevyUiPlugin, ChromeStatusLine},
        vim::{BevyVimInputPlugin, VimInputState},
    },
    buffer::{BufferOpenError, open_launch_buffer},
    config::{AppConfig, ConfigLoadError, ConfigProjectionError, FullscreenMode, WindowConfig},
    ecs::{
        BufferPlugin, EditorCorePlugin, FocusPlugin, InitialEditorBuffer, LayoutPlugin,
        TextShapePlugin,
    },
    render::TextRenderPlugin,
    scene::EditorViewPlugin,
    vim::VimConfig,
};
use bevy::{
    input::{ButtonInput, keyboard::KeyboardInput},
    prelude::{
        App, DefaultPlugins, KeyCode, MinimalPlugins, Plugin, PluginGroup, Query, Res, Startup,
        With, default,
    },
    window::{
        Monitor, MonitorSelection, PrimaryMonitor, PrimaryWindow, Window, WindowMode, WindowPlugin,
        WindowPosition,
    },
};
use haalka::prelude::HaalkaPlugin;
use std::{env, ffi::OsString, path::PathBuf};

/// Initial UTF-8 byte stream rendered by the managed text node.
const INITIAL_TEXT_STREAM: &str = include_str!("../lib.rs");

/// Title used for Alma's primary application window.
const WINDOW_TITLE: &str = "Alma";

/// Builds the primary window configuration for the interactive editor.
fn primary_window(config: &WindowConfig) -> Window {
    Window {
        title: String::from(WINDOW_TITLE),
        name: Some(String::from("alma")),
        mode: match config.fullscreen_mode {
            FullscreenMode::NativeFullscreen => {
                WindowMode::BorderlessFullscreen(MonitorSelection::Primary)
            }
            FullscreenMode::BorderlessWindowedFullscreen => WindowMode::Windowed,
        },
        position: match config.fullscreen_mode {
            FullscreenMode::NativeFullscreen => default(),
            FullscreenMode::BorderlessWindowedFullscreen => {
                WindowPosition::Centered(MonitorSelection::Primary)
            }
        },
        decorations: false,
        transparent: config.transparency_mode.is_window_transparent(),
        ..default()
    }
}

/// Resizes the primary window to cover the primary monitor for borderless windowed fullscreen.
#[allow(clippy::needless_pass_by_value)]
fn fit_borderless_windowed_fullscreen(
    app_config: Res<AppConfig>,
    monitors: Query<&Monitor, With<PrimaryMonitor>>,
    mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
    if app_config.window.fullscreen_mode != FullscreenMode::BorderlessWindowedFullscreen {
        return;
    }

    let Ok(monitor) = monitors.single() else {
        return;
    };
    let Ok(mut window) = windows.single_mut() else {
        return;
    };

    window.mode = WindowMode::Windowed;
    window.decorations = false;
    window.position = WindowPosition::At(monitor.physical_position);
    window
        .resolution
        .set_physical_resolution(monitor.physical_width, monitor.physical_height);
}

/// Headless editor dataflow plugins.
#[derive(Clone, Copy, Debug, Default)]
struct EditorDataflowPlugins;

impl Plugin for EditorDataflowPlugins {
    fn build(&self, app: &mut App) {
        let _app = app
            .add_plugins(EditorCorePlugin)
            .add_plugins(BufferPlugin)
            .add_plugins(FocusPlugin)
            .add_plugins(LayoutPlugin)
            .add_plugins(TextShapePlugin)
            .add_plugins(BevyVimInputPlugin);
    }
}

/// Windowed presentation plugins.
#[derive(Clone, Copy, Debug, Default)]
struct EditorPresentationPlugins;

impl Plugin for EditorPresentationPlugins {
    fn build(&self, app: &mut App) {
        let _app = app
            .add_plugins(HaalkaPlugin::new())
            .add_plugins(EditorViewPlugin)
            .add_plugins(TextRenderPlugin)
            .add_plugins(BevyUiPlugin);
    }
}

/// Runs the Alma Bevy application.
///
/// # Errors
///
/// Returns [`AlmaAppError`] when startup configuration fails, or when the launch file cannot be
/// opened or read as UTF-8.
pub fn run() -> Result<(), AlmaAppError> {
    run_with_launch_file(parse_launch_file_arg(env::args_os().skip(1))?)
}

/// Runs the Alma Bevy application with an already parsed launch-file argument.
///
/// # Errors
///
/// Returns [`AlmaAppError`] when startup configuration fails, or when the launch file cannot be
/// opened or read as UTF-8.
pub fn run_with_launch_file(launch_file: Option<PathBuf>) -> Result<(), AlmaAppError> {
    if env::var_os("ALMA_HEADLESS").is_some() {
        return run_headless_with_launch_file(launch_file);
    }

    let _exit = build_windowed_app(launch_file.map(PathBuf::into_os_string))?.run();

    Ok(())
}

/// Runs the Alma editor dataflow without windowing or rendering plugins.
///
/// This is intended for CI smoke tests, deterministic replay, and non-interactive validation.
///
/// # Errors
///
/// Returns [`AlmaAppError`] when startup configuration fails, or when the launch file cannot be
/// opened or read as UTF-8.
pub fn run_headless() -> Result<(), AlmaAppError> {
    run_headless_with_launch_file(parse_launch_file_arg(env::args_os().skip(1))?)
}

/// Parses Alma's retained binary argument shape.
fn parse_launch_file_arg(
    arguments: impl IntoIterator<Item = OsString>,
) -> Result<Option<PathBuf>, AlmaArgsError> {
    let mut arguments = arguments.into_iter();
    let Some(first) = arguments.next() else {
        return Ok(None);
    };

    if first.to_string_lossy().starts_with('-') {
        return Err(AlmaArgsError::UnsupportedOption);
    }
    if arguments.next().is_some() {
        return Err(AlmaArgsError::TooManyArguments);
    }

    Ok(Some(PathBuf::from(first)))
}

/// Runs the Alma editor dataflow with an already parsed launch-file argument.
fn run_headless_with_launch_file(launch_file: Option<PathBuf>) -> Result<(), AlmaAppError> {
    let update_count = env::var("ALMA_HEADLESS_UPDATES")
        .ok()
        .and_then(|value| value.parse::<usize>().ok())
        .unwrap_or(1);
    let mut app = build_headless_app(launch_file.map(PathBuf::into_os_string))?;

    for _update in 0..update_count {
        app.update();
    }

    Ok(())
}

/// Builds the normal interactive application.
fn build_windowed_app(launch_file: Option<OsString>) -> Result<App, AlmaAppError> {
    let app_config = AppConfig::load_or_default().map_err(AlmaAppError::Config)?;
    let filesystem_config = app_config
        .filesystem_config()
        .map_err(AlmaAppError::ConfigProjection)?;
    let (text_stream, buffer_file) =
        open_launch_buffer(launch_file, INITIAL_TEXT_STREAM, &filesystem_config)
            .map_err(AlmaAppError::BufferOpen)?;
    let vim_config = VimConfig::from(app_config.vim.clone());
    let resolved_theme = app_config.window.resolved_theme();

    let mut app = App::new();
    let _app = app
        .insert_resource(app_config.clone())
        .insert_resource(resolved_theme)
        .insert_resource(filesystem_config)
        .insert_resource(InitialEditorBuffer {
            stream: text_stream,
            file: buffer_file,
        })
        .insert_resource(vim_config)
        .insert_resource(VimInputState::default())
        .insert_resource(ChromeStatusLine::default())
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(primary_window(&app_config.window)),
            ..default()
        }))
        .add_systems(Startup, fit_borderless_windowed_fullscreen)
        .add_plugins(EditorDataflowPlugins)
        .add_plugins(EditorPresentationPlugins);

    Ok(app)
}

/// Builds the non-rendering editor dataflow application.
fn build_headless_app(launch_file: Option<OsString>) -> Result<App, AlmaAppError> {
    let app_config = AppConfig::load_or_default().map_err(AlmaAppError::Config)?;
    let filesystem_config = app_config
        .filesystem_config()
        .map_err(AlmaAppError::ConfigProjection)?;
    let (text_stream, buffer_file) =
        open_launch_buffer(launch_file, INITIAL_TEXT_STREAM, &filesystem_config)
            .map_err(AlmaAppError::BufferOpen)?;
    let vim_config = VimConfig::from(app_config.vim.clone());
    let resolved_theme = app_config.window.resolved_theme();

    let mut app = App::new();
    let _app = app
        .insert_resource(app_config)
        .insert_resource(resolved_theme)
        .insert_resource(filesystem_config)
        .insert_resource(InitialEditorBuffer {
            stream: text_stream,
            file: buffer_file,
        })
        .insert_resource(vim_config)
        .insert_resource(VimInputState::default())
        .insert_resource(ChromeStatusLine::default())
        .init_resource::<ButtonInput<KeyCode>>()
        .add_message::<KeyboardInput>()
        .add_plugins(MinimalPlugins)
        .add_plugins(EditorDataflowPlugins);

    Ok(app)
}

/// Errors that can occur while starting Alma.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AlmaAppError {
    /// Process arguments did not match Alma's supported binary shape.
    #[error("{0}")]
    Args(#[from] AlmaArgsError),
    /// Application configuration could not be loaded.
    #[error("{0}")]
    Config(#[from] ConfigLoadError),
    /// Application configuration could not be projected into runtime config.
    #[error("{0}")]
    ConfigProjection(#[from] ConfigProjectionError),
    /// The initial editor buffer could not be opened.
    #[error("{0}")]
    BufferOpen(#[from] BufferOpenError),
}

/// Errors produced while parsing Alma startup arguments.
#[derive(Debug, thiserror::Error)]
pub enum AlmaArgsError {
    /// Alma does not expose command-line flags.
    #[error("unsupported Alma option; usage: alma [FILE]")]
    UnsupportedOption,
    /// More than one launch-file argument was supplied.
    #[error("expected at most one file argument; usage: alma [FILE]")]
    TooManyArguments,
}

#[cfg(test)]
mod tests {
    use super::{AlmaArgsError, build_headless_app, parse_launch_file_arg, primary_window};
    use crate::config::{FullscreenMode, WindowConfig};
    use crate::ecs::components::buffer::EditorBuffer;
    use crate::presentation::{ResolvedTheme, TransparencyMode};
    use bevy::{
        prelude::{Entity, With},
        window::{MonitorSelection, WindowMode, WindowPosition},
    };
    use std::{ffi::OsString, path::PathBuf};

    #[test]
    fn headless_app_starts_editor_dataflow_without_windowing() {
        let mut app = build_headless_app(None).expect("headless app should build");

        app.update();

        let mut query = app
            .world_mut()
            .query_filtered::<Entity, With<EditorBuffer>>();
        let buffers = query.iter(app.world()).collect::<Vec<_>>();
        assert_eq!(buffers.len(), 1);
    }

    #[test]
    fn startup_args_accept_zero_or_one_launch_file() {
        assert_eq!(
            parse_launch_file_arg([]).expect("empty args should be valid"),
            None
        );
        assert_eq!(
            parse_launch_file_arg([OsString::from("notes.txt")])
                .expect("single file should be valid"),
            Some(PathBuf::from("notes.txt"))
        );
    }

    #[test]
    fn startup_args_reject_flags_and_extra_files() {
        let option = parse_launch_file_arg([OsString::from("--help")])
            .expect_err("flags should not be accepted");
        let extra = parse_launch_file_arg([OsString::from("a.txt"), OsString::from("b.txt")])
            .expect_err("extra file args should not be accepted");

        assert!(matches!(option, AlmaArgsError::UnsupportedOption));
        assert!(matches!(extra, AlmaArgsError::TooManyArguments));
    }

    #[test]
    fn primary_window_defaults_to_borderless_windowed_fullscreen() {
        let window = primary_window(&WindowConfig::default());

        assert_eq!(window.mode, WindowMode::Windowed);
        assert_eq!(
            window.position,
            WindowPosition::Centered(MonitorSelection::Primary)
        );
        assert!(!window.decorations);
        assert!(!window.transparent);
    }

    #[test]
    fn primary_window_supports_native_fullscreen() {
        let window = primary_window(&WindowConfig {
            fullscreen_mode: FullscreenMode::NativeFullscreen,
            transparency_mode: TransparencyMode::Opaque,
        });

        assert_eq!(
            window.mode,
            WindowMode::BorderlessFullscreen(MonitorSelection::Primary)
        );
        assert!(!window.decorations);
        assert!(!window.transparent);
    }

    #[test]
    fn primary_window_supports_borderless_windowed_fullscreen() {
        let window = primary_window(&WindowConfig {
            fullscreen_mode: FullscreenMode::BorderlessWindowedFullscreen,
            transparency_mode: TransparencyMode::Opaque,
        });

        assert_eq!(window.mode, WindowMode::Windowed);
        assert_eq!(
            window.position,
            WindowPosition::Centered(MonitorSelection::Primary)
        );
        assert!(!window.decorations);
        assert!(!window.transparent);
    }

    #[test]
    fn primary_window_maps_transparency_mode_to_bevy_window_flag() {
        let window = primary_window(&WindowConfig {
            fullscreen_mode: FullscreenMode::BorderlessWindowedFullscreen,
            transparency_mode: TransparencyMode::Transparent,
        });

        assert!(window.transparent);
    }

    #[test]
    fn headless_app_inserts_resolved_theme_resource() {
        let app = build_headless_app(None).expect("headless app should build");
        let theme = app.world().resource::<ResolvedTheme>();

        assert_eq!(theme.transparency_mode, TransparencyMode::Opaque);
    }
}