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};
const INITIAL_TEXT_STREAM: &str = include_str!("../lib.rs");
const WINDOW_TITLE: &str = "Alma";
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()
}
}
#[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);
}
#[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);
}
}
#[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);
}
}
pub fn run() -> Result<(), AlmaAppError> {
run_with_launch_file(parse_launch_file_arg(env::args_os().skip(1))?)
}
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(())
}
pub fn run_headless() -> Result<(), AlmaAppError> {
run_headless_with_launch_file(parse_launch_file_arg(env::args_os().skip(1))?)
}
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)))
}
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(())
}
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)
}
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)
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AlmaAppError {
#[error("{0}")]
Args(#[from] AlmaArgsError),
#[error("{0}")]
Config(#[from] ConfigLoadError),
#[error("{0}")]
ConfigProjection(#[from] ConfigProjectionError),
#[error("{0}")]
BufferOpen(#[from] BufferOpenError),
}
#[derive(Debug, thiserror::Error)]
pub enum AlmaArgsError {
#[error("unsupported Alma option; usage: alma [FILE]")]
UnsupportedOption,
#[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);
}
}