use concinnity_cook::authoring::world::entry_handles;
use concinnity_core::components::ShaderStage;
use concinnity_core::gfx::render_types::MAX_SHADER_BUCKETS;
use super::sdf_field_list::{FieldDecl, field_rows};
use super::shader_diagnostics::Tone;
use super::shader_source::{self, SourceKey, same_file};
use crate::debug::hot_reload::{ReloadFailure, ReloadOutcome, ReloadSubject, ReportBoard};
use crate::editor::select_related;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DeclaredFile {
pub(crate) stage: ShaderStage,
pub(crate) declared: String,
pub(crate) path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ShaderDecl {
pub(crate) name: String,
pub(crate) default: bool,
pub(crate) materials: Vec<(usize, String)>,
pub(crate) files: Vec<DeclaredFile>,
}
impl ShaderDecl {
pub(crate) fn file(&self, stage: ShaderStage) -> Option<&DeclaredFile> {
self.files.iter().find(|f| f.stage == stage)
}
}
pub(crate) fn declared(
entries: &[serde_json::Value],
mut resolve: impl FnMut(&str) -> String,
) -> Vec<ShaderDecl> {
let names = entry_handles(entries);
entries
.iter()
.enumerate()
.filter(|(_, e)| entry_type(e) == Some("Shader"))
.enumerate()
.filter_map(|(order, (i, e))| {
let name = names[i].clone()?;
let args = e.get("args");
let file = |stage: ShaderStage| {
let declared = args?.get(shader_source::stage_name(stage))?.as_str()?;
Some(DeclaredFile {
stage,
declared: declared.to_string(),
path: resolve(declared),
})
};
let files = [ShaderStage::Fragment, ShaderStage::Vertex]
.into_iter()
.filter_map(file)
.collect();
let materials = select_related::entries_using(entries, &name)
.into_iter()
.filter(|&m| entry_type(&entries[m]) == Some("Material"))
.filter_map(|m| Some((m, names[m].clone()?)))
.collect();
Some(ShaderDecl {
name,
default: order == 0,
materials,
files,
})
})
.collect()
}
pub(crate) fn shader_count(entries: &[serde_json::Value]) -> usize {
entries
.iter()
.filter(|e| entry_type(e) == Some("Shader"))
.count()
}
pub(crate) fn can_add_shader(count: usize) -> bool {
count < MAX_SHADER_BUCKETS
}
pub(crate) fn limit_reason() -> String {
format!("{MAX_SHADER_BUCKETS} Shaders is the most a world can declare")
}
fn entry_type(e: &serde_json::Value) -> Option<&str> {
e.get("type").and_then(|v| v.as_str())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FileStatus {
Built,
NotLive,
Ok(usize),
AppliesOnLoad(usize),
Failed,
SiblingFailed,
}
impl FileStatus {
pub(crate) fn label(self) -> String {
match self {
FileStatus::Built => "as built".to_string(),
FileStatus::NotLive => "after rebuild".to_string(),
FileStatus::Ok(0) => "ok".to_string(),
FileStatus::Ok(1) => "1 warning".to_string(),
FileStatus::Ok(n) => format!("{n} warnings"),
FileStatus::AppliesOnLoad(_) => "on scene load".to_string(),
FileStatus::Failed => "failed".to_string(),
FileStatus::SiblingFailed => "other file failed".to_string(),
}
}
pub(crate) fn tone(self) -> Tone {
match self {
FileStatus::Built | FileStatus::NotLive | FileStatus::Ok(0) => Tone::Info,
FileStatus::AppliesOnLoad(0) => Tone::Info,
FileStatus::Ok(_) | FileStatus::AppliesOnLoad(_) => Tone::Warning,
FileStatus::Failed | FileStatus::SiblingFailed => Tone::Error,
}
}
}
pub(crate) fn file_status(
board: &ReportBoard,
shader: &ShaderDecl,
file: &DeclaredFile,
) -> FileStatus {
let subject = ReloadSubject::shader(&shader.name);
if board.is_live(&subject) == Some(false) {
return FileStatus::NotLive;
}
let Some(latest) = board.latest(&subject) else {
return FileStatus::Built;
};
let warnings_here = |warnings: &[concinnity_cook::compile::program::Diagnostic]| {
warnings
.iter()
.filter(|w| same_file(&w.path, &file.path))
.count()
};
match &latest.outcome {
ReloadOutcome::Swapped { warnings, .. } => FileStatus::Ok(warnings_here(warnings)),
ReloadOutcome::AppliesOnLoad { warnings } => {
FileStatus::AppliesOnLoad(warnings_here(warnings))
}
ReloadOutcome::Failed(ReloadFailure::Compile(failed)) => {
let in_sibling = |path: &str| {
shader
.files
.iter()
.any(|f| f.stage != file.stage && same_file(path, &f.path))
};
let here = failed.errors().any(|e| same_file(&e.path, &file.path));
match !here && failed.errors().any(|e| in_sibling(&e.path)) {
true => FileStatus::SiblingFailed,
false => FileStatus::Failed,
}
}
ReloadOutcome::Failed(_) => FileStatus::Failed,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RowKind {
Header(usize),
Note,
Materials(usize),
File(SourceKey),
AddVertex(usize),
Section,
Field(usize),
FieldVolume(String),
New,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MenuItem {
Edit,
Duplicate,
Delete,
RemoveVertex,
Open,
SelectVolumes,
}
impl MenuItem {
pub(crate) fn caption(self) -> &'static str {
match self {
MenuItem::Edit => "Edit",
MenuItem::Duplicate => "Duplicate",
MenuItem::Delete => "Delete",
MenuItem::RemoveVertex => "Remove",
MenuItem::Open => "Open",
MenuItem::SelectVolumes => "Select volumes",
}
}
pub(crate) fn danger(self) -> bool {
matches!(self, MenuItem::Delete | MenuItem::RemoveVertex)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Row {
pub(crate) kind: RowKind,
pub(crate) text: String,
pub(crate) badge: Option<(String, Tone)>,
pub(crate) indent: bool,
pub(crate) selected: bool,
}
impl Row {
pub(crate) fn clickable(&self) -> bool {
!matches!(self.kind, RowKind::Note | RowKind::Section)
}
pub(crate) fn menu(&self) -> &'static [MenuItem] {
match &self.kind {
RowKind::Header(_) => &[MenuItem::Edit, MenuItem::Duplicate, MenuItem::Delete],
RowKind::File(SourceKey::Shader {
stage: ShaderStage::Vertex,
..
}) => &[MenuItem::RemoveVertex],
RowKind::Field(_) => &[MenuItem::Open, MenuItem::SelectVolumes, MenuItem::Delete],
_ => &[],
}
}
}
pub(crate) fn rows(
shaders: &[ShaderDecl],
fields: &[FieldDecl],
board: &ReportBoard,
open: Option<&SourceKey>,
) -> Vec<Row> {
let mut out = Vec::new();
for (i, shader) in shaders.iter().enumerate() {
out.push(Row {
kind: RowKind::Header(i),
text: shader.name.clone(),
badge: shader.default.then(|| ("default".to_string(), Tone::Info)),
indent: false,
selected: false,
});
out.push(materials_row(i, shader));
for file in &shader.files {
let key = SourceKey::shader(&shader.name, file.stage);
let status = file_status(board, shader, file);
out.push(Row {
selected: open == Some(&key),
kind: RowKind::File(key),
text: format!(
"{} {}",
shader_source::stage_name(file.stage),
file.declared
),
badge: Some((status.label(), status.tone())),
indent: true,
});
}
if shader.file(ShaderStage::Vertex).is_none() {
out.push(Row {
kind: RowKind::AddVertex(i),
text: "+ Add vertex file".to_string(),
badge: None,
indent: true,
selected: false,
});
}
}
out.extend(field_rows(fields, board, open));
out.push(Row {
kind: RowKind::New,
text: "+ New Shader".to_string(),
badge: (!can_add_shader(shaders.len()))
.then(|| ("SDF fields only".to_string(), Tone::Info)),
indent: false,
selected: false,
});
out
}
fn materials_row(i: usize, shader: &ShaderDecl) -> Row {
let names: Vec<&str> = shader.materials.iter().map(|(_, n)| n.as_str()).collect();
let (kind, text) = match (names.is_empty(), shader.default) {
(false, _) => (
RowKind::Materials(i),
format!("used by {}", names.join(", ")),
),
(true, true) => (
RowKind::Note,
"shades every Material naming no Shader".to_string(),
),
(true, false) => (RowKind::Note, "no Material names it".to_string()),
};
Row {
kind,
text,
badge: None,
indent: true,
selected: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::debug::hot_reload::{ReloadOutcome, ReloadReports};
use concinnity_cook::compile::program::{CompileFailure, Diagnostic, Severity};
use serde_json::json;
fn entries() -> Vec<serde_json::Value> {
vec![
json!({"type": "Shader", "args": {"$id": "lit", "fragment": "/cn-none/lit.hlsl"}}),
json!({"type": "Material", "args": {"$id": "plain"}}),
json!({"type": "Shader", "args": {
"$id": "reeds",
"fragment": "/cn-none/reeds.hlsl",
"vertex": "/cn-none/sway.hlsl",
}}),
json!({"type": "Material", "args": {"$id": "reed_mat", "shader": "reeds"}}),
json!({"type": "Material", "args": {"$id": "marsh_mat", "shader": "reeds"}}),
json!({"type": "Prop", "args": {"$id": "p", "material": "reed_mat"}}),
]
}
#[test]
fn shaders_are_listed_in_declaration_order() {
let shaders = declared(&entries(), str::to_string);
let names: Vec<&str> = shaders.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["lit", "reeds"]);
assert!(shaders[0].default && !shaders[1].default);
assert_eq!(shaders[0].files.len(), 1);
assert!(shaders[0].file(ShaderStage::Vertex).is_none());
let vertex = shaders[1].file(ShaderStage::Vertex).unwrap();
assert_eq!(vertex.declared, "/cn-none/sway.hlsl");
assert!(shaders[0].materials.is_empty());
assert_eq!(
shaders[1].materials,
[(3, "reed_mat".to_string()), (4, "marsh_mat".to_string())]
);
}
#[test]
fn an_anonymous_shader_is_listed_under_its_label() {
let entries = vec![json!({"type": "Shader", "args": {"fragment": "/cn-none/a.hlsl"}})];
assert_eq!(declared(&entries, str::to_string)[0].name, "Shader#0");
}
#[test]
fn rows_list_each_shader_then_the_new_row() {
let shaders = declared(&entries(), str::to_string);
let open = SourceKey::shader("reeds", ShaderStage::Vertex);
let rows = rows(&shaders, &[], &ReportBoard::default(), Some(&open));
let texts: Vec<&str> = rows.iter().map(|r| r.text.as_str()).collect();
assert_eq!(
texts,
[
"lit",
"shades every Material naming no Shader",
"fragment /cn-none/lit.hlsl",
"+ Add vertex file",
"reeds",
"used by reed_mat, marsh_mat",
"fragment /cn-none/reeds.hlsl",
"vertex /cn-none/sway.hlsl",
"+ New Shader",
]
);
assert_eq!(rows[0].badge, Some(("default".to_string(), Tone::Info)));
assert_eq!(rows[3].kind, RowKind::AddVertex(0));
assert!(rows[3].clickable());
assert_eq!(rows[4].badge, None);
assert_eq!(rows[5].kind, RowKind::Materials(1));
assert!(!rows[1].clickable());
assert!(rows[7].selected && !rows[6].selected);
assert_eq!(rows[8].kind, RowKind::New);
assert!(rows[8].clickable());
assert_eq!(rows[8].badge, None);
}
#[test]
fn menus_edit_duplicate_delete_or_remove_the_vertex_file() {
let shaders = declared(&entries(), str::to_string);
let rows = rows(&shaders, &[], &ReportBoard::default(), None);
let menu = |i: usize| rows[i].menu().to_vec();
assert_eq!(rows[4].kind, RowKind::Header(1));
assert_eq!(
menu(4),
[MenuItem::Edit, MenuItem::Duplicate, MenuItem::Delete]
);
assert!(rows[4].clickable(), "the header's body opens its form");
assert!(menu(6).is_empty(), "the fragment file");
assert_eq!(menu(7), [MenuItem::RemoveVertex]);
assert!(menu(5).is_empty() && menu(3).is_empty());
assert!(MenuItem::Delete.danger() && !MenuItem::Edit.danger());
assert!(!MenuItem::Duplicate.danger());
}
#[test]
fn the_new_row_creates_only_fields_at_the_shader_limit() {
let full: Vec<serde_json::Value> = (0..MAX_SHADER_BUCKETS)
.map(|i| json!({"type": "Shader", "args": {"$id": format!("s{i}"), "fragment": "/cn-none/s.hlsl"}}))
.collect();
assert!(!can_add_shader(shader_count(&full)));
assert!(can_add_shader(shader_count(&full[1..])));
let shaders = declared(&full, str::to_string);
let rows = rows(&shaders, &[], &ReportBoard::default(), None);
let last = rows.last().unwrap();
assert_eq!(last.kind, RowKind::New);
assert!(last.clickable());
assert_eq!(
last.badge,
Some(("SDF fields only".to_string(), Tone::Info))
);
}
fn error_in(path: &str) -> Diagnostic {
Diagnostic {
path: path.to_string(),
line: 1,
column: 1,
severity: Severity::Error,
message: "bad".to_string(),
context: String::new(),
}
}
fn failed(path: &str) -> ReloadOutcome {
ReloadOutcome::Failed(ReloadFailure::Compile(CompileFailure {
owner: "Shader 'reeds'".to_string(),
failures: Vec::new(),
diagnostics: vec![error_in(path)],
hint: "",
}))
}
fn statuses(board: &ReportBoard) -> Vec<FileStatus> {
let shaders = declared(&entries(), str::to_string);
shaders[1]
.files
.iter()
.map(|f| file_status(board, &shaders[1], f))
.collect()
}
fn board(name: &str, outcome: ReloadOutcome) -> ReportBoard {
let reports = ReloadReports::default();
reports.arm([ReloadSubject::shader("lit"), ReloadSubject::shader("reeds")]);
reports.publish(&[crate::debug::hot_reload::ReloadReport {
subject: ReloadSubject::shader(name),
outcome,
}]);
reports.snapshot()
}
#[test]
fn a_failure_is_charged_to_the_file_it_names() {
use FileStatus::*;
assert_eq!(
statuses(&board("reeds", failed("/cn-none/sway.hlsl"))),
[SiblingFailed, Failed]
);
assert_eq!(
statuses(&board("reeds", failed("main_bindless.hlsl"))),
[Failed, Failed]
);
}
#[test]
fn warnings_count_per_file_and_a_new_shader_waits_for_the_rebuild() {
use FileStatus::*;
let warning = Diagnostic {
severity: Severity::Warning,
..error_in("/cn-none/reeds.hlsl")
};
let swapped = ReloadOutcome::Swapped {
frame_time: std::time::Duration::ZERO,
warnings: vec![warning.clone()],
};
assert_eq!(statuses(&board("reeds", swapped)), [Ok(1), Ok(0)]);
let later = ReloadOutcome::AppliesOnLoad {
warnings: vec![warning],
};
assert_eq!(
statuses(&board("reeds", later)),
[AppliesOnLoad(1), AppliesOnLoad(0)]
);
assert_eq!(statuses(&ReportBoard::default()), [Built, Built]);
let reports = ReloadReports::default();
reports.arm([ReloadSubject::shader("lit")]);
assert_eq!(statuses(&reports.snapshot()), [NotLive, NotLive]);
}
}