1use std::rc::Rc;
4
5use ravenrs_app::{
6 ActionCategory, AppearanceTarget, ModelNode, Object, RuleKind, Severity, ViewInfo, Violation,
7};
8use ravenrs_app_a11y::AccessibleText;
9use ravenrs_layout::Align;
10use ravenrs_widgets::{
11 AccessibleControl, Alert, AnyView, BuildCx, FormField, FormGroup, FormLayout, NodeId, Text, Tone, VStack,
12 View,
13};
14
15use crate::editors::EditorRequest;
16use crate::session::{FormState, Session};
17use crate::values::semantic_of;
18
19#[derive(Debug)]
21#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
22pub struct DetailView {
23 session: Session,
24 view: ViewInfo,
25}
26
27#[cfg_attr(feature = "live", ravenrs_live::expose)]
28impl DetailView {
29 pub fn new(session: Session, view: ViewInfo) -> Self {
31 Self { session, view }
32 }
33
34 fn editor(
35 &self,
36 form: &Rc<FormState>,
37 node: &ModelNode,
38 field: &str,
39 state: &FormLook,
40 named_around: bool,
41 ) -> Option<AnyView> {
42 let info = form.entity.field_named(field)?;
43 let item = node.at(&["Items", field]);
44 let caption = item.and_then(|n| n.text("Caption")).unwrap_or(&info.caption).to_owned();
45 let caption =
46 self.session.form_caption().and_then(|words| words(field, &state.record)).unwrap_or(caption);
47 let look = form.entity.appearance_of(AppearanceTarget::Item, field, &self.view, Some(&state.record));
48 let around =
49 form.entity.appearance_of(AppearanceTarget::Layout, field, &self.view, Some(&state.record));
50 if look.visible == Some(false) || around.visible == Some(false) {
51 return None;
52 }
53 if let ravenrs_app::FieldKind::Collection(target) = &info.kind {
54 if let Some(source) = self.session.collection_source(&form.entity.name, field) {
55 let record = form.current();
56 let list = crate::nested::NestedList::given(
57 self.session.clone(),
58 target,
59 Rc::new(move || source(&record)),
60 );
61 return Some(if named_around {
62 Box::new(list) as AnyView
63 } else {
64 Box::new(FormField::new(caption, list))
65 });
66 }
67 let key = form.entity.key_field().map(|key| form.original.get(&key.name));
68 let list = crate::nested::NestedList::new(
69 self.session.clone(),
70 &form.entity,
71 (!form.is_new).then_some(key).flatten(),
72 target,
73 info.back_reference.as_deref(),
74 info.lookup_filter.as_deref(),
75 );
76 return Some(if named_around {
77 Box::new(list) as AnyView
78 } else {
79 Box::new(FormField::new(caption, list))
80 });
81 }
82 let mut read_only = item.and_then(|n| n.flag("ReadOnly")).unwrap_or(info.read_only);
83 if let Some(security) = self.session.frame().security() {
84 let (name, record) = (&form.entity.name, Some(&state.record));
85 let (can_read, can_write) = security.with(|p| {
86 (
87 p.can_field(ravenrs_app::Operation::Read, name, field, record),
88 p.can_field(ravenrs_app::Operation::Write, name, field, record),
89 )
90 });
91 if !can_read && !(form.is_new && can_write) {
92 let shown = Text::new(self.session.words().protected_value.clone());
93 return Some(Box::new(FormField::new(caption, shown)));
94 }
95 read_only |= !can_write;
96 }
97 let value = match form.fields.get(field) {
98 Some(value) => value.clone(),
99 None => {
100 let mut current = state.record.clone();
102 form.entity.compute(&mut current, &ravenrs_app::environment());
103 ravenrs_reactive::State::new(current.get(field))
104 }
105 };
106 let request = EditorRequest {
107 field: info.clone(),
108 value,
109 caption: caption.clone(),
110 read_only: read_only || (info.is_key && !form.is_new) || look.enabled == Some(false),
111 session: self.session.clone(),
112 };
113 let name = item.and_then(|n| n.text("Editor")).unwrap_or("");
114 let editor = self.session.editors().editor(name, &request);
115 let spoken = AccessibleText::of_field(info, item, &caption);
116 if matches!(info.kind, ravenrs_app::FieldKind::Boolean) {
118 let (error, note) = (state.error_of(field), state.note_of(field));
119 if error.is_none() && note.is_none() {
120 if !spoken.says_more() {
121 return Some(editor);
122 }
123 let mut named = AccessibleControl::new(editor).name(spoken.name);
124 if let Some(description) = spoken.description {
125 named = named.description(description);
126 }
127 return Some(Box::new(named));
128 }
129 let (note, tone) = note.map_or((None, Tone::Neutral), |(note, tone)| (Some(note), tone));
130 let wrapped = FormField::new(caption, editor).error_text(error).note_text(note, tone);
131 return Some(Box::new(Self::spoken(wrapped, spoken)));
132 }
133 let required = form
134 .entity
135 .rules
136 .iter()
137 .any(|rule| rule.field.as_deref() == Some(field) && matches!(rule.kind, RuleKind::Required));
138 let mut wrapped = FormField::new(caption, editor).error_text(state.error_of(field));
139 if let Some((note, tone)) = state.note_of(field) {
140 wrapped = wrapped.note_text(Some(note), tone);
141 }
142 if let Some(hint) = &info.hint {
143 wrapped = wrapped.hint(hint.clone());
144 }
145 if required {
146 wrapped = wrapped.required();
147 }
148 if let Some(tint) = look.font.or(around.font) {
149 wrapped = wrapped.label_ink(semantic_of(tint));
150 }
151 if let Some(tint) = look.back.or(around.back) {
152 wrapped = wrapped.tint(semantic_of(tint));
153 }
154 Some(Box::new(Self::spoken(wrapped, spoken)))
155 }
156
157 fn spoken(field: FormField<AnyView>, spoken: AccessibleText) -> FormField<AnyView> {
159 let field = if spoken.renamed { field.accessible_name(spoken.name) } else { field };
160 match spoken.description {
161 Some(description) => field.accessible_description(description),
162 None => field,
163 }
164 }
165
166 fn group(
167 &self,
168 form: &Rc<FormState>,
169 node: &ModelNode,
170 source: &ModelNode,
171 state: &FormLook,
172 in_tab: bool,
173 ) -> FormGroup {
174 let columns = source.integer("Columns").unwrap_or(1).max(1) as usize;
175 let title = source.text("Caption").filter(|_| !in_tab);
177 let mut group = match title {
178 Some(title) => FormGroup::titled(title),
179 None => FormGroup::new(),
180 }
181 .columns(columns);
182 for item in source.in_order() {
183 if item.text("Kind") == Some("ActionContainer") {
184 let name = item.text("Category").unwrap_or(&item.id).to_owned();
185 group = group
186 .wide(crate::ActionContainer::new(self.session.clone(), [ActionCategory::InForm(name)]));
187 continue;
188 }
189 let field = item.text("Field").unwrap_or(&item.id);
190 let tall = item.flag("Wide") == Some(true)
191 || node.at(&["Items", field]).and_then(|n| n.flag("Wide")) == Some(true)
192 || form.entity.field_named(field).is_some_and(|f| {
193 matches!(
194 f.kind,
195 ravenrs_app::FieldKind::LongText
196 | ravenrs_app::FieldKind::Image
197 | ravenrs_app::FieldKind::Collection(_)
198 )
199 });
200 let named_around =
201 in_tab || form.entity.field_named(field).is_some_and(|f| title == Some(f.caption.as_str()));
202 if let Some(editor) = self.editor(form, node, field, state, named_around) {
203 group = if tall { group.wide(editor) } else { group.field(editor) };
204 }
205 }
206 group
207 }
208
209 fn shows(&self, form: &FormState, part: &ModelNode, state: &FormLook) -> bool {
210 form.entity.appearance_of(AppearanceTarget::Layout, &part.id, &self.view, Some(&state.record)).visible
211 != Some(false)
212 }
213}
214
215fn tone_of(severity: Severity) -> Tone {
216 if severity == Severity::Warning { Tone::Warning } else { Tone::Accent }
217}
218
219struct FormLook {
221 record: Object,
222 violations: Vec<Violation>,
223}
224
225#[cfg_attr(feature = "live", ravenrs_live::expose)]
226impl FormLook {
227 fn error_of(&self, field: &str) -> Option<String> {
228 self.violations
229 .iter()
230 .find(|v| v.is_error() && v.field.as_deref() == Some(field))
231 .map(|v| v.message.clone())
232 }
233
234 fn note_of(&self, field: &str) -> Option<(String, Tone)> {
236 self.violations
237 .iter()
238 .find(|v| !v.is_error() && v.field.as_deref() == Some(field))
239 .map(|v| (v.message.clone(), tone_of(v.severity)))
240 }
241}
242
243impl View for DetailView {
244 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
245 let session = &self.session;
246 session.revision();
248 let model = session.model();
249 let Some(node) = model.at(&["Views", &self.view.id]) else {
250 return cx.child(&Text::new(self.view.id.clone()));
251 };
252 let Some(form) = session.form() else {
253 return cx.empty();
254 };
255 let state = FormLook { record: form.watched(), violations: session.form_violations() };
256 let lists_all = session.lists_field_errors();
257 let messages: Vec<String> = state
258 .violations
259 .iter()
260 .filter(|v| v.is_error() && (lists_all || v.field.is_none()))
261 .map(|v| v.message.clone())
262 .collect();
263 let notes: Vec<&Violation> =
264 state.violations.iter().filter(|v| !v.is_error() && v.field.is_none()).collect();
265 let note = (!notes.is_empty()).then(|| {
266 let text: Vec<&str> = notes.iter().map(|v| v.message.as_str()).collect();
267 let warns = notes.iter().any(|v| v.severity == Severity::Warning);
268 Alert::message(text.join("\n")).tone(if warns { Tone::Warning } else { Tone::Accent })
269 });
270 let mut layout = FormLayout::new().errors(messages);
271 if let Some(arrangement) = node.get("Layout") {
272 for part in arrangement.in_order() {
273 if !self.shows(&form, part, &state) {
274 continue;
275 }
276 if part.text("Kind") == Some("Tabs") {
277 for tab in part.in_order().into_iter().filter(|tab| self.shows(&form, tab, &state)) {
278 let title = tab.text("Caption").unwrap_or(&tab.id).to_owned();
279 layout = layout
280 .tab(title, FormLayout::new().group(self.group(&form, node, tab, &state, true)));
281 }
282 } else {
283 layout = layout.group(self.group(&form, node, part, &state, false));
284 }
285 }
286 }
287 match note {
288 Some(note) => cx.child(&VStack::new((note, layout)).spacing(12.0).align(Align::Stretch)),
289 None => cx.child(&layout),
290 }
291 }
292}