use std::path::{Path, PathBuf};
use std::time::SystemTime;
use concinnity_core::render::shader_programs::vocabulary::{self, Entry};
use super::path_cache::PathCache;
use crate::debug::hot_reload::{Latest, ReloadOutcome, ReloadReports, ReloadSubject, ReportBoard};
use crate::editor::panels::sdf_field_list::FieldDecl;
use crate::editor::panels::shader_diagnostics::{self, Status, Tone};
use crate::editor::panels::shader_edit::file_name;
use crate::editor::panels::shader_list::{Row, RowKind, ShaderDecl};
use crate::editor::panels::shader_reference::Reference;
use crate::editor::panels::shader_source::{self, DiskChange, SourceKey};
use crate::editor::text_area::TextArea;
use crate::editor::text_area::highlight::{HLSL, SDF_HLSL};
use crate::editor::text_area::markers::GutterMarker;
#[derive(Debug)]
pub(in crate::editor::hook) struct ShadersState {
pub(in crate::editor::hook) scroll: usize,
pub(in crate::editor::hook) menu: Option<RowKind>,
pub(in crate::editor::hook) reports: ReloadReports,
pub(in crate::editor::hook) source: Option<SourceState>,
pub(in crate::editor::hook) paths: PathCache,
pub(in crate::editor::hook) field_paths: PathCache,
pub(in crate::editor::hook) paths_dir: Option<PathBuf>,
pub(in crate::editor::hook) retry_at: f64,
pub(in crate::editor::hook) resolve: fn(&str, Option<&Path>) -> String,
pub(in crate::editor::hook) resolve_field: fn(&str, Option<&Path>) -> String,
pub(in crate::editor::hook) rows: Vec<Row>,
pub(in crate::editor::hook) rows_key: Option<RowsKey>,
pub(in crate::editor::hook) reference: Reference,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(in crate::editor::hook) struct RowsKey {
pub(in crate::editor::hook) shaders: Vec<ShaderDecl>,
pub(in crate::editor::hook) fields: Vec<FieldDecl>,
pub(in crate::editor::hook) seq: u64,
pub(in crate::editor::hook) open: Option<SourceKey>,
}
impl Default for ShadersState {
fn default() -> Self {
Self {
scroll: 0,
menu: None,
reports: ReloadReports::default(),
source: None,
paths: PathCache::default(),
field_paths: PathCache::default(),
paths_dir: None,
retry_at: 0.0,
resolve: shader_source::resolve_path,
resolve_field: shader_source::resolve_field_path,
rows: Vec::new(),
rows_key: None,
reference: Reference::default(),
}
}
}
impl ShadersState {
pub(in crate::editor::hook) fn reset_for_world(&mut self) {
self.scroll = 0;
self.menu = None;
self.source = None;
self.paths.clear();
self.field_paths.clear();
self.rows.clear();
self.rows_key = None;
}
pub(in crate::editor::hook) fn resolved(
&mut self,
declared: &str,
dir: Option<&Path>,
) -> String {
self.follow_dir(dir);
let resolve = self.resolve;
self.paths.get(declared, |d| resolve(d, dir))
}
pub(in crate::editor::hook) fn resolved_field(
&mut self,
declared: &str,
dir: Option<&Path>,
) -> String {
self.follow_dir(dir);
let resolve = self.resolve_field;
self.field_paths.get(declared, |d| resolve(d, dir))
}
pub(in crate::editor::hook) fn retry_missing(&mut self, now: f64) {
if now < self.retry_at {
return;
}
self.retry_at = now + MISSING_RETRY_S;
self.paths.forget_missing();
self.field_paths.forget_missing();
}
fn follow_dir(&mut self, dir: Option<&Path>) {
if self.paths_dir.as_deref() != dir {
self.paths.clear();
self.field_paths.clear();
self.paths_dir = dir.map(Path::to_path_buf);
}
}
}
#[derive(Debug)]
pub(in crate::editor::hook) struct SourceState {
pub(in crate::editor::hook) key: SourceKey,
pub(in crate::editor::hook) path: String,
pub(in crate::editor::hook) area: TextArea,
pub(in crate::editor::hook) focus: bool,
pub(in crate::editor::hook) status: Option<Status>,
pub(in crate::editor::hook) markers: Vec<GutterMarker>,
pub(in crate::editor::hook) jump: Option<(usize, usize)>,
pub(in crate::editor::hook) seen: u64,
pub(in crate::editor::hook) compiling: bool,
pub(in crate::editor::hook) live: Option<bool>,
pub(in crate::editor::hook) known: String,
pub(in crate::editor::hook) mtime: Option<SystemTime>,
pub(in crate::editor::hook) noticed: Option<String>,
pub(in crate::editor::hook) next_check: f64,
}
pub(in crate::editor::hook) fn vocabulary_of(key: &SourceKey) -> &'static [Entry] {
match key {
SourceKey::Shader { .. } => vocabulary::ENTRIES,
SourceKey::Field { .. } => vocabulary::sdf::ENTRIES,
}
}
fn source_area(key: &SourceKey, text: &str) -> TextArea {
let highlighter = match key {
SourceKey::Shader { .. } => &HLSL,
SourceKey::Field { .. } => &SDF_HLSL,
};
TextArea::from_text(text).highlighted(highlighter)
}
fn shown<'a>(newer: &[&'a Latest]) -> Option<&'a Latest> {
newer
.iter()
.find(|l| matches!(l.outcome, ReloadOutcome::Failed(_)))
.or_else(|| newer.iter().max_by_key(|l| l.seq))
.copied()
}
const DISK_CHECK_S: f64 = 0.5;
const MISSING_RETRY_S: f64 = 1.0;
impl SourceState {
pub(in crate::editor::hook) fn new(key: SourceKey, path: String, text: String) -> Self {
let area = source_area(&key, &text);
Self {
key,
mtime: modified(&path),
area,
path,
focus: false,
status: None,
markers: Vec::new(),
jump: None,
seen: 0,
compiling: false,
live: None,
known: text,
noticed: None,
next_check: 0.0,
}
}
pub(in crate::editor::hook) fn title(&self) -> String {
match &self.key {
SourceKey::Shader { name, stage } => {
format!("{name} {}", shader_source::stage_name(*stage))
}
SourceKey::Field { path } => format!("{} SDF field", file_name(path)),
}
}
fn owner(&self) -> &'static str {
match self.key {
SourceKey::Shader { .. } => "Shader",
SourceKey::Field { .. } => "SDF field",
}
}
pub(in crate::editor::hook) fn file_name(&self) -> &str {
self.path.rsplit(['/', '\\']).next().unwrap_or(&self.path)
}
pub(in crate::editor::hook) fn take_board(
&mut self,
board: &ReportBoard,
subjects: &[ReloadSubject],
) -> bool {
let live: Vec<Option<bool>> = subjects.iter().map(|s| board.is_live(s)).collect();
self.live = match live.as_slice() {
[] => None,
l if l.contains(&Some(false)) => Some(false),
l if l.iter().all(|l| *l == Some(true)) => Some(true),
_ => None,
};
let newer: Vec<&Latest> = subjects
.iter()
.filter_map(|s| board.latest(s))
.filter(|l| l.seq > self.seen)
.collect();
if let Some(latest) = shown(&newer) {
self.seen = newer.iter().map(|l| l.seq).max().unwrap_or(latest.seq);
let view = shader_diagnostics::report_view(&latest.outcome, &self.path);
let answered = std::mem::take(&mut self.compiling);
self.markers = view.markers;
self.status = Some(view.status);
self.jump = view.jump;
return answered && self.jump.is_some();
}
if let Some(armed) = board.armed_at()
&& armed > self.seen
{
self.seen = armed;
if std::mem::take(&mut self.compiling) {
self.markers.clear();
self.jump = None;
self.status = Some(Status::new("Compiled with the rebuilt world", Tone::Info));
}
}
false
}
pub(in crate::editor::hook) fn saved(&mut self, text: String) {
self.area.mark_saved();
self.known = text;
self.noticed = None;
self.mtime = modified(&self.path);
match self.live {
Some(false) => {
let owner = self.owner();
self.status = Some(Status::new(
format!("Saved; the {owner} joins the running world when it rebuilds"),
Tone::Info,
))
}
_ => {
self.compiling = true;
self.status = Some(Status::new("Compiling...", Tone::Info));
}
}
}
pub(in crate::editor::hook) fn check_disk(&mut self, now: f64) {
if now < self.next_check {
return;
}
self.next_check = now + DISK_CHECK_S;
let mtime = modified(&self.path);
if mtime == self.mtime {
return;
}
self.mtime = mtime;
let Ok(disk) = std::fs::read_to_string(&self.path) else {
return;
};
match shader_source::disk_change(
&self.known,
&disk,
self.area.is_dirty(),
self.noticed.as_deref(),
) {
DiskChange::Unchanged => {}
DiskChange::Reload => {
let caret = self.area.caret();
self.area = source_area(&self.key, &disk);
self.area.go_to(caret.line, caret.col);
self.known = disk;
self.noticed = None;
}
DiskChange::Notice => {
self.status = Some(Status::new(
"The file changed on disk; Save overwrites it with these edits",
Tone::Warning,
));
self.noticed = Some(disk);
}
}
}
}
fn modified(path: &str) -> Option<SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::debug::hot_reload::{ReloadFailure, ReloadOutcome, ReloadReport, ReloadSubject};
use concinnity_cook::compile::program::{CompileFailure, Diagnostic, Severity};
use concinnity_core::components::ShaderStage;
fn state(path: &str, text: &str) -> SourceState {
SourceState::new(
SourceKey::shader("water", ShaderStage::Fragment),
path.to_string(),
text.to_string(),
)
}
fn water() -> [ReloadSubject; 1] {
[ReloadSubject::shader("water")]
}
fn failed_at(path: &str, line: u32) -> ReloadOutcome {
ReloadOutcome::Failed(ReloadFailure::Compile(CompileFailure {
owner: "Shader 'water'".to_string(),
failures: Vec::new(),
diagnostics: vec![Diagnostic {
path: path.to_string(),
line,
column: 3,
severity: Severity::Error,
message: "bad".to_string(),
context: String::new(),
}],
hint: "",
}))
}
fn publish(reports: &ReloadReports, outcome: ReloadOutcome) {
reports.publish(&[ReloadReport {
subject: ReloadSubject::shader("water"),
outcome,
}]);
}
#[test]
fn a_save_is_answered_by_its_report() {
let reports = ReloadReports::default();
reports.arm([ReloadSubject::shader("water")]);
let mut s = state("/cn-none/water.hlsl", "a\nb\nc");
assert!(!s.take_board(&reports.snapshot(), &water()));
assert_eq!(s.live, Some(true));
s.saved("a\nb\nc".to_string());
assert!(s.compiling);
publish(&reports, failed_at("/cn-none/water.hlsl", 2));
assert!(
s.take_board(&reports.snapshot(), &water()),
"the save's error jumps"
);
assert!(!s.compiling);
assert_eq!(s.markers.len(), 1);
assert_eq!(s.jump, Some((1, 2)));
assert!(!s.take_board(&reports.snapshot(), &water()), "taken once");
publish(
&reports,
ReloadOutcome::Swapped {
frame_time: std::time::Duration::ZERO,
warnings: Vec::new(),
},
);
assert!(!s.take_board(&reports.snapshot(), &water()));
assert!(s.markers.is_empty());
assert_eq!(s.jump, None);
}
#[test]
fn an_unasked_report_marks_without_jumping() {
let reports = ReloadReports::default();
reports.arm([ReloadSubject::shader("water")]);
let mut s = state("/cn-none/water.hlsl", "a\nb");
publish(&reports, failed_at("/cn-none/water.hlsl", 1));
assert!(!s.take_board(&reports.snapshot(), &water()));
assert_eq!(s.markers.len(), 1);
}
#[test]
fn a_new_shader_waits_for_the_rebuild() {
let reports = ReloadReports::default();
reports.arm([ReloadSubject::shader("lit")]);
let mut s = state("/cn-none/water.hlsl", "a");
s.take_board(&reports.snapshot(), &water());
assert_eq!(s.live, Some(false));
s.saved("a".to_string());
assert!(!s.compiling);
reports.arm([ReloadSubject::shader("lit"), ReloadSubject::shader("water")]);
s.take_board(&reports.snapshot(), &water());
s.saved("a".to_string());
assert!(s.compiling);
reports.arm([ReloadSubject::shader("lit"), ReloadSubject::shader("water")]);
s.take_board(&reports.snapshot(), &water());
assert!(!s.compiling);
assert_eq!(s.status.as_ref().unwrap().tone, Tone::Info);
}
#[test]
fn a_field_takes_the_reports_of_every_volume_reading_it() {
let volumes = ["a", "b"].map(ReloadSubject::sdf_volume);
let key = SourceKey::Field {
path: "/cn-none/blob.hlsl".to_string(),
};
let mut s = SourceState::new(key, "/cn-none/blob.hlsl".to_string(), "a\nb".to_string());
assert_eq!(s.title(), "blob.hlsl SDF field");
let reports = ReloadReports::default();
reports.arm([ReloadSubject::sdf_volume("a")]);
s.take_board(&reports.snapshot(), &volumes);
assert_eq!(s.live, Some(false), "b joins at the rebuild");
s.saved("a\nb".to_string());
assert!(!s.compiling);
assert!(s.status.as_ref().unwrap().text.contains("SDF field"));
reports.arm(volumes.clone());
s.take_board(&reports.snapshot(), &volumes);
assert_eq!(s.live, Some(true));
s.saved("a\nb".to_string());
reports.publish(&[
ReloadReport {
subject: volumes[0].clone(),
outcome: failed_at("/cn-none/blob.hlsl", 2),
},
ReloadReport {
subject: volumes[1].clone(),
outcome: ReloadOutcome::Swapped {
frame_time: std::time::Duration::ZERO,
warnings: Vec::new(),
},
},
]);
assert!(
s.take_board(&reports.snapshot(), &volumes),
"the failure jumps"
);
assert_eq!(s.jump, Some((1, 2)));
assert!(!s.take_board(&reports.snapshot(), &volumes), "taken once");
}
#[test]
fn a_missing_file_is_found_once_a_retry_is_due() {
let dir = tempfile::tempdir().unwrap();
let shaders = dir.path().join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
let mut s = ShadersState::default();
let resolved = |s: &mut ShadersState, name: &str| s.resolved(name, Some(dir.path()));
assert_eq!(
resolved(&mut s, "water.hlsl"),
dir.path().join("water.hlsl").to_string_lossy()
);
std::fs::write(shaders.join("water.hlsl"), "").unwrap();
s.retry_missing(0.0);
assert_eq!(
resolved(&mut s, "water.hlsl"),
shaders.join("water.hlsl").to_string_lossy()
);
resolved(&mut s, "lava.hlsl");
std::fs::write(shaders.join("lava.hlsl"), "").unwrap();
s.retry_missing(0.5);
assert_eq!(
resolved(&mut s, "lava.hlsl"),
dir.path().join("lava.hlsl").to_string_lossy(),
"not due yet"
);
s.retry_missing(1.0);
assert_eq!(
resolved(&mut s, "lava.hlsl"),
shaders.join("lava.hlsl").to_string_lossy()
);
}
#[test]
fn a_change_on_disk_reloads_clean_and_notices_dirty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("water.hlsl");
std::fs::write(&path, "one").unwrap();
let mut s = state(&path.to_string_lossy(), "one");
std::fs::write(&path, "two").unwrap();
s.mtime = None;
s.check_disk(0.0);
assert_eq!(s.area.text(), "two");
assert!(!s.area.is_dirty());
assert!(s.status.is_none(), "a clean reload is silent");
s.area.type_char('x');
std::fs::write(&path, "three").unwrap();
s.mtime = None;
s.check_disk(0.1);
assert!(s.noticed.is_none(), "throttled");
s.check_disk(1.0);
assert_eq!(s.area.text(), "xtwo", "the edits are kept");
assert_eq!(s.status.as_ref().unwrap().tone, Tone::Warning);
assert_eq!(s.noticed.as_deref(), Some("three"));
std::fs::write(&path, s.area.text()).unwrap();
s.saved(s.area.text());
s.mtime = None;
s.check_disk(2.0);
assert_eq!(s.area.text(), "xtwo", "the save is what the file holds");
}
}