Skip to content

Commit

Permalink
feat: watch file code snippet files
Browse files Browse the repository at this point in the history
  • Loading branch information
mfontanini committed Sep 21, 2024
1 parent 0056105 commit e927f8b
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 59 deletions.
35 changes: 0 additions & 35 deletions src/input/fs.rs

This file was deleted.

1 change: 0 additions & 1 deletion src/input/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
pub(crate) mod fs;
pub(crate) mod source;
pub(crate) mod user;
24 changes: 8 additions & 16 deletions src/input/source.rs
Original file line number Diff line number Diff line change
@@ -1,40 +1,32 @@
use super::{
fs::PresentationFileWatcher,
user::{CommandKeyBindings, KeyBindingsValidationError, UserInput},
};
use super::user::{CommandKeyBindings, KeyBindingsValidationError, UserInput};
use crate::custom::KeyBindingsConfig;
use serde::Deserialize;
use std::{io, path::PathBuf, time::Duration};
use std::{io, time::Duration};
use strum::EnumDiscriminants;

/// The source of commands.
///
/// This expects user commands as well as watches over the presentation file to reload if it that
/// happens.
pub struct CommandSource {
watcher: PresentationFileWatcher,
user_input: UserInput,
}

impl CommandSource {
/// Create a new command source over the given presentation path.
pub fn new<P: Into<PathBuf>>(
presentation_path: P,
config: KeyBindingsConfig,
) -> Result<Self, KeyBindingsValidationError> {
let watcher = PresentationFileWatcher::new(presentation_path);
pub fn new(config: KeyBindingsConfig) -> Result<Self, KeyBindingsValidationError> {
let bindings = CommandKeyBindings::try_from(config)?;
Ok(Self { watcher, user_input: UserInput::new(bindings) })
Ok(Self { user_input: UserInput::new(bindings) })
}

/// Try to get the next command.
///
/// This attempts to get a command and returns `Ok(None)` on timeout.
pub(crate) fn try_next_command(&mut self) -> io::Result<Option<Command>> {
if let Some(command) = self.user_input.poll_next_command(Duration::from_millis(250))? {
return Ok(Some(command));
};
if self.watcher.has_modifications()? { Ok(Some(Command::Reload)) } else { Ok(None) }
match self.user_input.poll_next_command(Duration::from_millis(250))? {
Some(command) => Ok(Some(command)),
None => Ok(None),
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ fn run(mut cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", serde_json::to_string_pretty(&meta)?);
}
} else {
let commands = CommandSource::new(&path, config.bindings.clone())?;
let commands = CommandSource::new(config.bindings.clone())?;
options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty { .. });

let options = PresenterOptions {
Expand Down
22 changes: 17 additions & 5 deletions src/presenter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ impl<'a> Presenter<'a> {

/// Run a presentation.
pub fn present(mut self, path: &Path) -> Result<(), PresentationError> {
if matches!(self.options.mode, PresentMode::Development) {
self.resources.watch_presentation_file(path.to_path_buf());
}
self.state = PresenterState::Presenting(Presentation::from(vec![]));
self.try_reload(path, true);

Expand All @@ -104,11 +107,18 @@ impl<'a> Presenter<'a> {
if self.poll_async_renders()? {
self.render(&mut drawer)?;
}
let Some(command) = self.commands.try_next_command()? else {
if self.check_async_error() {
break;
}
continue;

let command = match self.commands.try_next_command()? {
Some(command) => command,
_ => match self.resources.resources_modified() {
true => Command::Reload,
false => {
if self.check_async_error() {
break;
}
continue;
}
},
};
match self.apply_command(command) {
CommandSideEffect::Exit => return Ok(()),
Expand Down Expand Up @@ -262,6 +272,7 @@ impl<'a> Presenter<'a> {
return;
}
self.slides_with_pending_async_renders.clear();
self.resources.clear_watches();
match self.load_presentation(path) {
Ok(mut presentation) => {
let current = self.state.presentation();
Expand All @@ -280,6 +291,7 @@ impl<'a> Presenter<'a> {
// file.
PresentMode::Export => presentation.trigger_all_async_renders(),
};

self.state = self.validate_overflows(presentation);
}
Err(e) => {
Expand Down
128 changes: 127 additions & 1 deletion src/resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ use crate::{
};
use std::{
collections::HashMap,
fs, io,
fs, io, mem,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
mpsc::{channel, Receiver, Sender},
Arc,
},
thread,
time::{Duration, SystemTime},
};

const LOOP_INTERVAL: Duration = Duration::from_millis(250);

/// Manages resources pulled from the filesystem such as images.
///
/// All resources are cached so once a specific resource is loaded, looking it up with the same
Expand All @@ -18,22 +27,29 @@ pub struct Resources {
themes: HashMap<PathBuf, PresentationTheme>,
external_snippets: HashMap<PathBuf, String>,
image_registry: ImageRegistry,
watcher: FileWatcherHandle,
}

impl Resources {
/// Construct a new resource manager over the provided based path.
///
/// Any relative paths will be assumed to be relative to the given base.
pub fn new<P: Into<PathBuf>>(base_path: P, image_registry: ImageRegistry) -> Self {
let watcher = FileWatcher::spawn();
Self {
base_path: base_path.into(),
images: Default::default(),
themes: Default::default(),
external_snippets: Default::default(),
image_registry,
watcher,
}
}

pub(crate) fn watch_presentation_file(&self, path: PathBuf) {
self.watcher.send(WatchEvent::WatchFile { path, watch_forever: true });
}

/// Get the image at the given path.
pub(crate) fn image<P: AsRef<Path>>(&mut self, path: P) -> Result<Image, LoadImageError> {
let path = self.base_path.join(path);
Expand Down Expand Up @@ -66,10 +82,21 @@ impl Resources {
}

let contents = fs::read_to_string(&path)?;
self.watcher.send(WatchEvent::WatchFile { path: path.clone(), watch_forever: false });
self.external_snippets.insert(path, contents.clone());
Ok(contents)
}

pub(crate) fn resources_modified(&mut self) -> bool {
self.watcher.has_modifications()
}

pub(crate) fn clear_watches(&mut self) {
self.watcher.send(WatchEvent::ClearWatches);
// We could do better than this but this works for now.
self.external_snippets.clear();
}

/// Clears all resources.
pub(crate) fn clear(&mut self) {
self.images.clear();
Expand All @@ -86,3 +113,102 @@ pub enum LoadImageError {
#[error(transparent)]
RegisterImage(#[from] RegisterImageError),
}

/// Watches for file changes.
///
/// This uses polling rather than something fancier like `inotify`. The latter turned out to make
/// code too complex for little added gain. This instead keeps the last modified time for all
/// watched paths and uses that to determine if they've changed.
struct FileWatcher {
receiver: Receiver<WatchEvent>,
watches: HashMap<PathBuf, WatchMetadata>,
modifications: Arc<AtomicBool>,
}

impl FileWatcher {
fn spawn() -> FileWatcherHandle {
let (sender, receiver) = channel();
let modifications = Arc::new(AtomicBool::default());
let handle = FileWatcherHandle { sender, modifications: modifications.clone() };
thread::spawn(move || {
let watcher = FileWatcher { receiver, watches: Default::default(), modifications };
watcher.run();
});
handle
}

fn run(mut self) {
loop {
if let Ok(event) = self.receiver.try_recv() {
self.handle_event(event);
}
if self.watches_modified() {
self.modifications.store(true, Ordering::Relaxed);
}
thread::sleep(LOOP_INTERVAL);
}
}

fn handle_event(&mut self, event: WatchEvent) {
match event {
WatchEvent::ClearWatches => {
let new_watches =
mem::take(&mut self.watches).into_iter().filter(|(_, meta)| meta.watch_forever).collect();
self.watches = new_watches;
}
WatchEvent::WatchFile { path, watch_forever } => {
let last_modification =
fs::metadata(&path).and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
let meta = WatchMetadata { last_modification, watch_forever };
self.watches.insert(path, meta);
}
}
}

fn watches_modified(&mut self) -> bool {
let mut modifications = false;
for (path, meta) in &mut self.watches {
let Ok(metadata) = fs::metadata(path) else {
// If the file no longer exists, it's technically changed since last time.
modifications = true;
continue;
};
let Ok(modified_time) = metadata.modified() else {
continue;
};
if modified_time > meta.last_modification {
meta.last_modification = modified_time;
modifications = true;
}
}
modifications
}
}

struct WatchMetadata {
last_modification: SystemTime,
watch_forever: bool,
}

struct FileWatcherHandle {
sender: Sender<WatchEvent>,
modifications: Arc<AtomicBool>,
}

impl FileWatcherHandle {
fn send(&self, event: WatchEvent) {
let _ = self.sender.send(event);
}

fn has_modifications(&mut self) -> bool {
self.modifications.swap(false, Ordering::Relaxed)
}
}

enum WatchEvent {
/// Clear all watched files.
ClearWatches,

/// Add a file to the watch list.
WatchFile { path: PathBuf, watch_forever: bool },
}

0 comments on commit e927f8b

Please sign in to comment.