670 lines
21 KiB
Rust
670 lines
21 KiB
Rust
use clap::{command, Parser};
|
|
use core::service_items::{ServiceItem, ServiceItemModel};
|
|
use cosmic::app::context_drawer::ContextDrawer;
|
|
use cosmic::app::{Core, Settings, Task};
|
|
use cosmic::iced::keyboard::{Key, Modifiers};
|
|
use cosmic::iced::window::{Mode, Position};
|
|
use cosmic::iced::{self, event, window, Length, Padding, Point};
|
|
use cosmic::iced_futures::Subscription;
|
|
use cosmic::iced_widget::{column, row};
|
|
use cosmic::prelude::ElementExt;
|
|
use cosmic::prelude::*;
|
|
use cosmic::widget::nav_bar::nav_bar_style;
|
|
use cosmic::widget::tooltip::Position as TPosition;
|
|
use cosmic::widget::{button, nav_bar, text, tooltip, Space};
|
|
use cosmic::widget::{icon, slider};
|
|
use cosmic::{executor, Application, ApplicationExt, Element};
|
|
use cosmic::{widget::Container, Theme};
|
|
use crisp::types::Value;
|
|
use lisp::parse_lisp;
|
|
use miette::{miette, Result};
|
|
use std::fs::read_to_string;
|
|
use std::path::PathBuf;
|
|
use tracing::{debug, level_filters::LevelFilter};
|
|
use tracing::{error, warn};
|
|
use tracing_subscriber::EnvFilter;
|
|
use ui::library::Library;
|
|
|
|
pub mod core;
|
|
pub mod lisp;
|
|
pub mod ui;
|
|
use core::slide::*;
|
|
use ui::presenter::{self, Presenter};
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(name = "lumina", version, about)]
|
|
struct Cli {
|
|
#[arg(short, long)]
|
|
watch: bool,
|
|
#[arg(short = 'i', long)]
|
|
ui: bool,
|
|
file: PathBuf,
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
let timer = tracing_subscriber::fmt::time::ChronoLocal::new(
|
|
"%Y-%m-%d_%I:%M:%S%.6f %P".to_owned(),
|
|
);
|
|
let filter = EnvFilter::builder()
|
|
.with_default_directive(LevelFilter::WARN.into())
|
|
.parse_lossy("lumina=debug");
|
|
tracing_subscriber::FmtSubscriber::builder()
|
|
.pretty()
|
|
.with_line_number(true)
|
|
.with_level(true)
|
|
.with_target(true)
|
|
.with_env_filter(filter)
|
|
.with_target(true)
|
|
.with_timer(timer)
|
|
.init();
|
|
|
|
let args = Cli::parse();
|
|
|
|
let settings;
|
|
if args.ui {
|
|
debug!("main view");
|
|
settings = Settings::default().debug(false);
|
|
} else {
|
|
debug!("window view");
|
|
settings =
|
|
Settings::default().debug(false).no_main_window(true);
|
|
}
|
|
|
|
cosmic::app::run::<App>(settings, args)
|
|
.map_err(|e| miette!("Invalid things... {}", e))
|
|
}
|
|
|
|
fn theme(_state: &App) -> Theme {
|
|
Theme::dark()
|
|
}
|
|
|
|
struct App {
|
|
core: Core,
|
|
nav_model: nav_bar::Model,
|
|
file: PathBuf,
|
|
presenter: Presenter,
|
|
windows: Vec<window::Id>,
|
|
slides: Vec<Slide>,
|
|
current_slide: Slide,
|
|
presentation_open: bool,
|
|
cli_mode: bool,
|
|
library: Library,
|
|
library_open: bool,
|
|
library_width: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Message {
|
|
Present(presenter::Message),
|
|
File(PathBuf),
|
|
DndEnter(ServiceItem),
|
|
DndDrop(ServiceItem),
|
|
OpenWindow,
|
|
CloseWindow(Option<window::Id>),
|
|
WindowOpened(window::Id, Option<Point>),
|
|
WindowClosed(window::Id),
|
|
Quit,
|
|
Key(Key, Modifiers),
|
|
None,
|
|
}
|
|
|
|
impl cosmic::Application for App {
|
|
type Executor = executor::Default;
|
|
type Flags = Cli;
|
|
type Message = Message;
|
|
const APP_ID: &'static str = "lumina";
|
|
fn core(&self) -> &Core {
|
|
&self.core
|
|
}
|
|
fn core_mut(&mut self) -> &mut Core {
|
|
&mut self.core
|
|
}
|
|
fn init(
|
|
core: Core,
|
|
input: Self::Flags,
|
|
) -> (Self, Task<Self::Message>) {
|
|
debug!("init");
|
|
let mut nav_model = nav_bar::Model::default();
|
|
|
|
let mut windows = vec![];
|
|
|
|
if input.ui {
|
|
windows.push(core.main_window_id().unwrap());
|
|
}
|
|
|
|
let items = match read_to_string(input.file) {
|
|
Ok(lisp) => {
|
|
let mut slide_vector = vec![];
|
|
let lisp = crisp::reader::read(&lisp);
|
|
match lisp {
|
|
Value::List(vec) => {
|
|
for value in vec {
|
|
let mut inner_vector = parse_lisp(value);
|
|
slide_vector.append(&mut inner_vector);
|
|
}
|
|
}
|
|
_ => todo!(),
|
|
}
|
|
slide_vector
|
|
}
|
|
Err(e) => {
|
|
warn!("Missing file or could not read: {e}");
|
|
vec![]
|
|
}
|
|
};
|
|
|
|
let items = ServiceItemModel::from(items);
|
|
let presenter = Presenter::with_items(items.clone());
|
|
let slides = items.to_slides().unwrap_or_default();
|
|
let current_slide = slides[0].clone();
|
|
|
|
for item in items.iter() {
|
|
nav_model.insert().text(item.title()).data(item.clone());
|
|
}
|
|
|
|
nav_model.activate_position(0);
|
|
|
|
let mut app = App {
|
|
presenter,
|
|
core,
|
|
nav_model,
|
|
file: PathBuf::default(),
|
|
windows,
|
|
slides,
|
|
current_slide,
|
|
presentation_open: false,
|
|
cli_mode: !input.ui,
|
|
library: Library::new(&items),
|
|
library_open: true,
|
|
library_width: 60.0,
|
|
};
|
|
|
|
let command;
|
|
if input.ui {
|
|
debug!("main view");
|
|
command = app.update_title()
|
|
} else {
|
|
debug!("window view");
|
|
command = app.show_window()
|
|
};
|
|
|
|
(app, command)
|
|
}
|
|
|
|
/// Allows COSMIC to integrate with your application's [`nav_bar::Model`].
|
|
fn nav_model(&self) -> Option<&nav_bar::Model> {
|
|
Some(&self.nav_model)
|
|
}
|
|
|
|
fn nav_bar(
|
|
&self,
|
|
) -> Option<Element<cosmic::app::Message<Message>>> {
|
|
if !self.core().nav_bar_active() {
|
|
return None;
|
|
}
|
|
|
|
let nav_model = self.nav_model()?;
|
|
|
|
let mut nav = cosmic::widget::nav_bar(nav_model, |id| {
|
|
cosmic::app::Message::Cosmic(
|
|
cosmic::app::cosmic::Message::NavBar(id),
|
|
)
|
|
})
|
|
.on_context(|id| {
|
|
cosmic::app::Message::Cosmic(
|
|
cosmic::app::cosmic::Message::NavBarContext(id),
|
|
)
|
|
})
|
|
.context_menu(None)
|
|
.into_container()
|
|
// XXX both must be shrink to avoid flex layout from ignoring it
|
|
.width(Length::Shrink)
|
|
.height(Length::Shrink);
|
|
|
|
if !self.core().is_condensed() {
|
|
nav = nav.max_width(280);
|
|
}
|
|
|
|
let column = column![
|
|
text::heading("Service List").center().width(280),
|
|
nav
|
|
]
|
|
.spacing(10);
|
|
let padding = Padding::new(0.0).top(20);
|
|
let container = Container::new(column)
|
|
.style(nav_bar_style)
|
|
.padding(padding);
|
|
Some(container.into())
|
|
}
|
|
|
|
/// Called when a navigation item is selected.
|
|
fn on_nav_select(
|
|
&mut self,
|
|
id: nav_bar::Id,
|
|
) -> Task<Self::Message> {
|
|
self.nav_model.activate(id);
|
|
debug!(?id);
|
|
self.update_title()
|
|
}
|
|
|
|
fn header_start(&self) -> Vec<Element<Self::Message>> {
|
|
vec![]
|
|
}
|
|
fn header_center(&self) -> Vec<Element<Self::Message>> {
|
|
vec![]
|
|
}
|
|
fn header_end(&self) -> Vec<Element<Self::Message>> {
|
|
let presenter_window = self.windows.get(1);
|
|
let text = if self.presentation_open {
|
|
text::body("Close Presentation")
|
|
} else {
|
|
text::body("Open Presentation")
|
|
};
|
|
vec![tooltip(
|
|
button::custom(
|
|
row!(
|
|
Container::new(
|
|
icon::from_name(if self.presentation_open {
|
|
"dialog-close"
|
|
} else {
|
|
"view-presentation-symbolic"
|
|
})
|
|
.scale(3)
|
|
)
|
|
.center_y(Length::Fill),
|
|
text
|
|
)
|
|
.padding(5)
|
|
.spacing(5),
|
|
)
|
|
.class(cosmic::theme::style::Button::HeaderBar)
|
|
.on_press({
|
|
if self.presentation_open {
|
|
Message::CloseWindow(presenter_window.copied())
|
|
} else {
|
|
Message::OpenWindow
|
|
}
|
|
}),
|
|
"Start Presentation",
|
|
TPosition::Bottom,
|
|
)
|
|
.into()]
|
|
}
|
|
|
|
fn footer(&self) -> Option<Element<Self::Message>> {
|
|
Some(text::body("Sux").into())
|
|
}
|
|
|
|
fn subscription(&self) -> Subscription<Self::Message> {
|
|
event::listen_with(|event, _, id| {
|
|
// debug!(?event);
|
|
match event {
|
|
iced::Event::Keyboard(event) => match event {
|
|
iced::keyboard::Event::KeyPressed {
|
|
key,
|
|
modifiers,
|
|
..
|
|
} => Some(Message::Key(key, modifiers)),
|
|
_ => None,
|
|
},
|
|
iced::Event::Mouse(event) => None,
|
|
iced::Event::Window(window_event) => {
|
|
match window_event {
|
|
window::Event::CloseRequested => {
|
|
debug!("Closing window");
|
|
Some(Message::CloseWindow(Some(id)))
|
|
}
|
|
window::Event::Opened {
|
|
position, ..
|
|
} => {
|
|
debug!(?window_event, ?id);
|
|
Some(Message::WindowOpened(id, position))
|
|
}
|
|
window::Event::Closed => {
|
|
debug!("Closed window");
|
|
Some(Message::WindowClosed(id))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
iced::Event::Touch(event) => None,
|
|
iced::Event::A11y(id, action_request) => None,
|
|
iced::Event::Dnd(dnd_event) => None,
|
|
iced::Event::PlatformSpecific(platform_specific) => {
|
|
None
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn context_drawer(
|
|
&self,
|
|
) -> Option<
|
|
cosmic::app::context_drawer::ContextDrawer<Self::Message>,
|
|
> {
|
|
ContextDrawer {
|
|
title: Some("Context".into()),
|
|
header_actions: vec![],
|
|
header: Some("hi".into()),
|
|
content: "Sup".into(),
|
|
footer: Some("foot".into()),
|
|
on_close: Message::None,
|
|
};
|
|
None
|
|
}
|
|
|
|
fn update(&mut self, message: Message) -> Task<Message> {
|
|
match message {
|
|
Message::Key(key, modifiers) => {
|
|
debug!(?key, ?modifiers);
|
|
match (key, modifiers) {
|
|
(
|
|
Key::Named(
|
|
iced::keyboard::key::Named::ArrowRight,
|
|
),
|
|
_,
|
|
) => self.update(Message::Present(
|
|
presenter::Message::NextSlide,
|
|
)),
|
|
(
|
|
Key::Named(
|
|
iced::keyboard::key::Named::ArrowLeft,
|
|
),
|
|
_,
|
|
) => self.update(Message::Present(
|
|
presenter::Message::PrevSlide,
|
|
)),
|
|
(
|
|
Key::Named(iced::keyboard::key::Named::Space),
|
|
_,
|
|
) => self.update(Message::Present(
|
|
presenter::Message::NextSlide,
|
|
)),
|
|
(Key::Character(k), _)
|
|
if k == *"j" || k == *"l" =>
|
|
{
|
|
self.update(Message::Present(
|
|
presenter::Message::NextSlide,
|
|
))
|
|
}
|
|
(Key::Character(k), _)
|
|
if k == *"k" || k == *"h" =>
|
|
{
|
|
self.update(Message::Present(
|
|
presenter::Message::PrevSlide,
|
|
))
|
|
}
|
|
(Key::Character(k), _) if k == *"q" => {
|
|
self.update(Message::Quit)
|
|
}
|
|
_ => Task::none(),
|
|
}
|
|
}
|
|
Message::Present(message) => {
|
|
// debug!(?message);
|
|
if self.presentation_open {
|
|
if let Some(video) = &mut self.presenter.video {
|
|
video.set_muted(false);
|
|
}
|
|
}
|
|
self.presenter.update(message).map(|x| {
|
|
debug!(?x);
|
|
cosmic::app::Message::App(Message::None)
|
|
})
|
|
}
|
|
Message::File(file) => {
|
|
self.file = file;
|
|
Task::none()
|
|
}
|
|
Message::OpenWindow => {
|
|
let count = self.windows.len() + 1;
|
|
|
|
let (id, spawn_window) =
|
|
window::open(window::Settings {
|
|
position: Position::Centered,
|
|
exit_on_close_request: count % 2 == 0,
|
|
decorations: false,
|
|
..Default::default()
|
|
});
|
|
|
|
self.windows.push(id);
|
|
_ = self.set_window_title(
|
|
format!("window_{}", count),
|
|
id,
|
|
);
|
|
|
|
spawn_window.map(|id| {
|
|
cosmic::app::Message::App(Message::WindowOpened(
|
|
id, None,
|
|
))
|
|
})
|
|
}
|
|
Message::CloseWindow(id) => {
|
|
if let Some(id) = id {
|
|
window::close(id)
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
Message::WindowOpened(id, _) => {
|
|
debug!(?id, "Window opened");
|
|
if self.cli_mode
|
|
|| id > self.core.main_window_id().expect("Cosmic core seems to be missing a main window, was this started in cli mode?")
|
|
{
|
|
self.presentation_open = true;
|
|
if let Some(video) = &mut self.presenter.video {
|
|
video.set_muted(false);
|
|
}
|
|
window::change_mode(id, Mode::Fullscreen)
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
Message::WindowClosed(id) => {
|
|
warn!("Closing window: {id}");
|
|
let Some(window) =
|
|
self.windows.iter().position(|w| *w == id)
|
|
else {
|
|
error!("Nothing matches this window id: {id}");
|
|
return Task::none();
|
|
};
|
|
self.windows.remove(window);
|
|
// This closes the app if using the cli example
|
|
if self.windows.is_empty() {
|
|
self.update(Message::Quit)
|
|
} else {
|
|
self.presentation_open = false;
|
|
if let Some(video) = &mut self.presenter.video {
|
|
video.set_muted(true);
|
|
}
|
|
Task::none()
|
|
}
|
|
}
|
|
Message::Quit => cosmic::iced::exit(),
|
|
Message::DndEnter(service_item) => todo!(),
|
|
Message::DndDrop(service_item) => todo!(),
|
|
Message::None => Task::none(),
|
|
}
|
|
}
|
|
|
|
// Main window view
|
|
fn view(&self) -> Element<Message> {
|
|
let icon_left = icon::from_name("arrow-left");
|
|
let icon_right = icon::from_name("arrow-right");
|
|
|
|
let video_range = self.presenter.video.as_ref().map_or_else(
|
|
|| 0.0,
|
|
|video| video.duration().as_secs_f32(),
|
|
);
|
|
|
|
let video_button_icon =
|
|
if let Some(video) = &self.presenter.video {
|
|
let (icon_name, tooltip) = if video.paused() {
|
|
("media-play", "Play")
|
|
} else {
|
|
("media-pause", "Pause")
|
|
};
|
|
button::icon(icon::from_name(icon_name))
|
|
.tooltip(tooltip)
|
|
.on_press(Message::Present(
|
|
presenter::Message::StartVideo,
|
|
))
|
|
} else {
|
|
button::icon(icon::from_name("media-play"))
|
|
.tooltip("Play")
|
|
.on_press(Message::Present(
|
|
presenter::Message::StartVideo,
|
|
))
|
|
};
|
|
|
|
let slide_preview = column![
|
|
Space::with_height(Length::Fill),
|
|
Container::new(
|
|
self.presenter.view_preview().map(Message::Present),
|
|
)
|
|
.align_bottom(Length::Fill),
|
|
Container::new(if self.presenter.video.is_some() {
|
|
row![
|
|
video_button_icon,
|
|
Container::new(
|
|
slider(
|
|
0.0..=video_range,
|
|
self.presenter.video_position,
|
|
|pos| {
|
|
Message::Present(
|
|
presenter::Message::VideoPos(pos),
|
|
)
|
|
}
|
|
)
|
|
.step(0.1)
|
|
)
|
|
.center_x(Length::Fill)
|
|
.padding([7, 0, 0, 0])
|
|
]
|
|
.padding(5)
|
|
} else {
|
|
row![]
|
|
})
|
|
.center_x(Length::Fill),
|
|
Space::with_height(Length::Fill),
|
|
]
|
|
.spacing(3);
|
|
|
|
let library = Container::new(self.library.view())
|
|
.center(Length::Fill)
|
|
.width(self.library_width);
|
|
// let drag_handle = Container::new(Space::new(1, Length::Fill))
|
|
// .style(|t| nav_bar_style(t));
|
|
// let dragger = MouseArea::new(drag_handle)
|
|
// .on_drag(Message::LibraryWidth);
|
|
|
|
let row = row![
|
|
Container::new(
|
|
button::icon(icon_left)
|
|
.icon_size(128)
|
|
.tooltip("Previous Slide")
|
|
.width(128)
|
|
.on_press(Message::Present(
|
|
presenter::Message::PrevSlide
|
|
))
|
|
)
|
|
.center_y(Length::Fill)
|
|
.align_right(Length::Fill)
|
|
.width(Length::FillPortion(2)),
|
|
Container::new(slide_preview)
|
|
.center_y(Length::Fill)
|
|
.width(Length::FillPortion(3)),
|
|
Container::new(
|
|
button::icon(icon_right)
|
|
.icon_size(128)
|
|
.tooltip("Next Slide")
|
|
.width(128)
|
|
.on_press(Message::Present(
|
|
presenter::Message::NextSlide
|
|
))
|
|
)
|
|
.center_y(Length::Fill)
|
|
.align_left(Length::Fill)
|
|
.width(Length::FillPortion(2)),
|
|
]
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.spacing(20);
|
|
|
|
let column = column![
|
|
Container::new(row).center_y(Length::Fill),
|
|
Container::new(
|
|
self.presenter.preview_bar().map(Message::Present)
|
|
)
|
|
.clip(true)
|
|
.width(Length::Fill)
|
|
.center_y(130)
|
|
];
|
|
|
|
let element: Element<Message> = column.into();
|
|
element
|
|
}
|
|
|
|
// View for presentation
|
|
fn view_window(&self, _id: window::Id) -> Element<Message> {
|
|
self.presenter.view().map(Message::Present)
|
|
}
|
|
}
|
|
|
|
impl App
|
|
where
|
|
Self: cosmic::Application,
|
|
{
|
|
fn active_page_title(&self) -> &str {
|
|
let Some(label) =
|
|
self.nav_model.text(self.nav_model.active())
|
|
else {
|
|
return "Lumina";
|
|
};
|
|
label
|
|
}
|
|
|
|
fn update_title(&mut self) -> Task<Message> {
|
|
let header_title = self.active_page_title().to_owned();
|
|
let window_title = format!("{header_title} — Lumina");
|
|
self.core.main_window_id().map_or_else(Task::none, |id| {
|
|
self.set_window_title(window_title, id)
|
|
})
|
|
}
|
|
|
|
fn show_window(&mut self) -> Task<Message> {
|
|
let (id, spawn_window) = window::open(window::Settings {
|
|
position: Position::Centered,
|
|
exit_on_close_request: true,
|
|
decorations: false,
|
|
..Default::default()
|
|
});
|
|
self.windows.push(id);
|
|
_ = self.set_window_title("Lumina Presenter".to_owned(), id);
|
|
spawn_window.map(|id| {
|
|
cosmic::app::Message::App(Message::WindowOpened(id, None))
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn test_slide() -> String {
|
|
let slide = r#"(slide (image :source "./somehting.jpg" :fill cover
|
|
(text "Something cooler" :font-size 50)))"#;
|
|
String::from(slide)
|
|
}
|
|
// #[test]
|
|
// fn test_lisp() {
|
|
// let slide = test_slide();
|
|
// if let Ok(data) = lexpr::parse::from_str_elisp(slide.as_str()) {
|
|
// assert_eq!(slide, data)
|
|
// } else {
|
|
// assert!(false)
|
|
// }
|
|
// }
|
|
}
|