Skip to main content

banished_ui/
document.rs

1//! Caret-markup documents, traced from Ghidra's DocumentTextElement::GetNextItem, LayoutRow, and
2//! Draw.
3//!
4//! Original: Runtime `0x1800d07b0`, Runtime `0x1800d0430`, Runtime `0x1800d0010`.
5use crate::controls::intersect;
6use crate::*;
7use banished_formats::ui;
8use banished_formats::{
9    crc::type_id,
10    crs,
11    error::{Error, Result},
12};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone)]
16pub struct Document {
17    pub(crate) text: String,
18    pub(crate) width: i32,
19    pub(crate) size: [i32; 2],
20    /// A link can occupy several rectangles, one per wrapped text/image run.
21    pub(crate) links: Vec<(String, [i32; 4])>,
22    pub(crate) hovered_link: Option<usize>,
23    pub(crate) pressed_link: Option<usize>,
24    fonts: Vec<Option<FontId>>,
25    sheets: Vec<Option<SheetId>>,
26    colors: Vec<u32>,
27    bullets: Vec<Option<SpriteRef>>,
28    underline: Option<SpriteRef>,
29    link_colors: [u32; 3],
30    indent: i32,
31    items: Vec<Item>,
32    captures: HashMap<String, Capture>,
33}
34
35#[derive(Debug, Clone)]
36struct Item {
37    content: Content,
38    pos: [i32; 2],
39    size: [i32; 2],
40    color: u32,
41    link: Option<String>,
42}
43#[derive(Debug, Clone)]
44enum Content {
45    Text(TextData),
46    Sprite(SpriteRef),
47    Capture(Capture),
48    Space,
49}
50
51#[derive(Debug, Clone)]
52struct Capture {
53    size: [i32; 2],
54    parts: Vec<CapturePart>,
55}
56#[derive(Debug, Clone)]
57struct CapturePart {
58    sheet: i32,
59    sprite: Option<SpriteRef>,
60    rect: [i32; 4],
61    color: u32,
62}
63
64struct RawCapture {
65    sheets: Vec<Option<String>>,
66    size: [i32; 2],
67    parts: Vec<(i32, String, [i32; 4], u32)>,
68    rotated_parts: usize,
69}
70
71/// SpriteCapture::Serialize (0x1800dbda0) and its record serializer (0x18004cbd0).
72fn read_capture(bytes: &[u8]) -> Result<RawCapture> {
73    crs::parse(bytes, |r, h| {
74        if h.type_id != type_id("SpriteCapture") {
75            return Err(Error::Unsupported("expected SpriteCapture".into()));
76        }
77        r.block(|r, _| {
78            let sheets = (0..r.count()?)
79                .map(|_| r.external_ref())
80                .collect::<Result<Vec<_>>>()?;
81            let size = [r.i32()?, r.i32()?];
82            let mut rotated_parts = 0;
83            let parts = (0..r.count()?)
84                .map(|_| {
85                    let sheet = r.i32()?;
86                    let name = r.string()?;
87                    let rect = [r.i32()?, r.i32()?, r.i32()?, r.i32()?];
88                    let angle = r.f32()?;
89                    rotated_parts += usize::from(angle != 0.0);
90                    let color = r.u32()?;
91                    Ok((sheet, name, rect, color))
92                })
93                .collect::<Result<_>>()?;
94            Ok(RawCapture {
95                sheets,
96                size,
97                parts,
98                rotated_parts,
99            })
100        })
101    })
102    .map(|(_, capture)| capture)
103}
104
105fn read_capture_list(bytes: &[u8]) -> Result<Vec<Option<String>>> {
106    crs::parse(bytes, |r, h| {
107        if h.type_id != type_id("ExternalList") {
108            return Err(Error::Unsupported("expected ExternalList".into()));
109        }
110        r.block(|r, _| (0..r.count()?).map(|_| r.external_ref()).collect())
111    })
112    .map(|(_, list)| list)
113}
114
115fn capture_name(reference: &str) -> String {
116    reference
117        .split(':')
118        .next()
119        .unwrap_or(reference)
120        .replace('\\', "/")
121        .to_ascii_lowercase()
122}
123
124#[derive(Debug, Clone, PartialEq)]
125enum Token {
126    Word(String),
127    Command(char, Option<char>, String),
128}
129
130/// Whitespace in documents separates words; explicit ^n and ^p perform line/paragraph breaks.
131/// Operands run until whitespace or another caret, exactly as FindNextWhiteSpace (0x1800d1840).
132fn tokenize(text: &str) -> Vec<Token> {
133    let mut chars = text.chars().peekable();
134    let mut out = Vec::new();
135    while let Some(ch) = chars.next() {
136        if ch.is_ascii_whitespace() {
137            continue;
138        }
139        if ch != '^' {
140            let mut word = String::from(ch);
141            while chars
142                .peek()
143                .is_some_and(|c| !c.is_ascii_whitespace() && *c != '^')
144            {
145                word.push(chars.next().unwrap());
146            }
147            out.push(Token::Word(word));
148            continue;
149        }
150        let Some(command) = chars.next() else {
151            break;
152        };
153        let suffix = if "fcjvmlai".contains(command) {
154            chars.next()
155        } else {
156            None
157        };
158        let argument = if command == 'd'
159            || command == 'i'
160            || (command == 'a' && suffix == Some('b'))
161            || (command == 'm' && matches!(suffix, Some('l' | 'r')))
162        {
163            let mut value = String::new();
164            while chars
165                .peek()
166                .is_some_and(|c| !c.is_ascii_whitespace() && *c != '^')
167            {
168                value.push(chars.next().unwrap());
169            }
170            value
171        } else {
172            String::new()
173        };
174        out.push(Token::Command(command, suffix, argument));
175    }
176    out
177}
178
179struct Row {
180    start: usize,
181    x: i32,
182    y: i32,
183    left: i32,
184    right: i32,
185    align: char,
186    vertical: char,
187    font: usize,
188    color: usize,
189    link: Option<String>,
190    restore_left: i32,
191}
192
193impl Ui {
194    /// Resolve the link fragment index supplied by Event::Link to its document target.
195    pub fn document_link(&self, id: NodeId, index: usize) -> Option<&str> {
196        match &self.nodes.get(id)?.kind {
197            Kind::Control(Control::Document(doc)) => {
198                doc.links.get(index).map(|(target, _)| target.as_str())
199            }
200            _ => None,
201        }
202    }
203
204    pub(crate) fn load_document(&mut self, spec: &ui::DocumentText) -> Document {
205        let fonts = spec
206            .fonts
207            .iter()
208            .map(|f| f.as_deref().and_then(|f| self.res.font(f)))
209            .collect();
210        let sheets = spec
211            .sheets
212            .iter()
213            .map(|s| s.as_deref().and_then(|s| self.res.sheet(s)))
214            .collect();
215        let bullets = spec
216            .bullets
217            .iter()
218            .map(|name| self.res.sprite(spec.sprite_sheet.as_deref(), name))
219            .collect();
220        let mut captures = HashMap::new();
221        if let Some(reference) = &spec.images {
222            let refs = self
223                .res
224                .archive
225                .read_ref(reference)
226                .and_then(|d| read_capture_list(&d));
227            match refs {
228                Ok(refs) => {
229                    for reference in refs.into_iter().flatten() {
230                        match self
231                            .res
232                            .archive
233                            .read_ref(&reference)
234                            .and_then(|d| read_capture(&d))
235                        {
236                            Ok(raw) => {
237                                if raw.rotated_parts > 0 {
238                                    log::warn!("capture {reference}: {} rotated sprites use axis-aligned quads",raw.rotated_parts);
239                                }
240                                let parts = raw
241                                    .parts
242                                    .into_iter()
243                                    .map(|(sheet, name, rect, color)| CapturePart {
244                                        sheet,
245                                        sprite: usize::try_from(sheet)
246                                            .ok()
247                                            .and_then(|i| raw.sheets.get(i))
248                                            .and_then(|s| self.res.sprite(s.as_deref(), &name)),
249                                        rect,
250                                        color,
251                                    })
252                                    .collect();
253                                captures.insert(
254                                    capture_name(&reference),
255                                    Capture {
256                                        size: raw.size,
257                                        parts,
258                                    },
259                                );
260                            }
261                            Err(e) => log::warn!("document capture {reference}: {e}"),
262                        }
263                    }
264                }
265                Err(e) => log::warn!("document capture list {reference}: {e}"),
266            }
267        }
268        Document {
269            text: self.res.string(spec.string_table.as_deref(), &spec.text),
270            width: spec.width.max(1),
271            size: [0, 0],
272            links: Vec::new(),
273            hovered_link: None,
274            pressed_link: None,
275            fonts,
276            sheets,
277            bullets,
278            colors: spec.colors.clone(),
279            underline: self
280                .res
281                .sprite(spec.sprite_sheet.as_deref(), &spec.underline_sprite),
282            link_colors: spec.link_colors,
283            indent: spec.indent,
284            items: Vec::new(),
285            captures,
286        }
287    }
288
289    pub(crate) fn layout_document(&self, doc: &mut Document) {
290        doc.items.clear();
291        doc.links.clear();
292        let mut row = Row {
293            start: 0,
294            x: 0,
295            y: 0,
296            left: 0,
297            right: doc.width.max(1),
298            align: 'l',
299            vertical: 'b',
300            font: 0,
301            color: 0,
302            link: None,
303            restore_left: 0,
304        };
305        let mut columns = [0, 0];
306        let tokens = tokenize(&doc.text);
307        for (token_index, token) in tokens.iter().cloned().enumerate() {
308            match token {
309                Token::Word(word) => {
310                    let font = doc.fonts.get(row.font).copied().flatten();
311                    let text = format!("{word} ");
312                    let layout = font
313                        .map(|f| TextLayout::compute(&self.res, f, &text, None, 0))
314                        .unwrap_or_default();
315                    let size = [layout.width, layout.height];
316                    let data = TextData {
317                        font,
318                        text,
319                        max_width: 0,
320                        justify: 0,
321                        wrap: false,
322                        shadow: false,
323                        ellipsis_width: 0,
324                        layout,
325                    };
326                    self.document_item(doc, &mut row, Content::Text(data), size);
327                }
328                Token::Command(command, suffix, arg) => {
329                    match (command, suffix) {
330                        ('n', _) => self.document_end_row(doc, &mut row),
331                        ('p', _) => {
332                            self.document_end_row(doc, &mut row);
333                            self.document_end_row(doc, &mut row);
334                        }
335                        ('f', Some(d)) => {
336                            if let Some(index) = d.to_digit(10) {
337                                if (index as usize) < doc.fonts.len() {
338                                    row.font = index as usize;
339                                }
340                            }
341                        }
342                        ('c', Some(d)) => {
343                            if let Some(index) = d.to_digit(10) {
344                                if (index as usize) < doc.colors.len() {
345                                    row.color = index as usize;
346                                }
347                            }
348                        }
349                        ('j', Some(j @ ('l' | 'r' | 'c'))) => {
350                            if row.start < doc.items.len() {
351                                self.document_end_row(doc, &mut row);
352                            }
353                            row.align = j;
354                        }
355                        ('v', Some(v @ ('t' | 'c' | 'b'))) => {
356                            if row.start < doc.items.len() {
357                                self.document_end_row(doc, &mut row);
358                            }
359                            row.vertical = v;
360                        }
361                        ('m', Some('l' | 'r')) => {
362                            if row.start < doc.items.len() {
363                                self.document_end_row(doc, &mut row);
364                            }
365                            let value = arg.parse::<i32>().unwrap_or(0);
366                            if suffix == Some('l') {
367                                row.left = value.max(0);
368                                row.x = row.left;
369                            } else {
370                                row.right = if value < 0 { doc.width } else { value };
371                            }
372                        }
373                        ('m', Some('b' | 'c' | 'e')) => {
374                            if row.start < doc.items.len() {
375                                self.document_end_row(doc, &mut row);
376                            }
377                            match suffix {
378                                Some('b') => columns = [row.y, row.y],
379                                Some('c') => {
380                                    columns[1] = columns[1].max(row.y);
381                                    row.y = columns[0];
382                                }
383                                _ => {
384                                    columns[1] = columns[1].max(row.y);
385                                    row.y = columns[1];
386                                }
387                            }
388                            row.x = row.left;
389                        }
390                        ('l', Some('b' | 'e')) => {
391                            if row.start < doc.items.len() {
392                                self.document_end_row(doc, &mut row);
393                            }
394                            row.left = if suffix == Some('b') {
395                                row.left + doc.indent
396                            } else {
397                                row.left - doc.indent
398                            }
399                            .max(0);
400                            row.x = row.left;
401                        }
402                        ('l', Some('i')) => {
403                            if row.start < doc.items.len() {
404                                self.document_end_row(doc, &mut row);
405                            }
406                            if let Some(sprite) = doc.bullets.get(row.font).copied().flatten() {
407                                row.left -= sprite.width;
408                                row.x = row.left;
409                                row.restore_left = sprite.width;
410                                self.document_item(
411                                    doc,
412                                    &mut row,
413                                    Content::Sprite(sprite),
414                                    [sprite.width, sprite.height],
415                                );
416                            }
417                        }
418                        ('l', Some('o')) => {
419                            if row.start < doc.items.len() {
420                                self.document_end_row(doc, &mut row);
421                            }
422                            // GetNextItem previews the next item's width for a hanging list marker.
423                            let font = doc.fonts.get(row.font).copied().flatten();
424                            let marker_width = tokens[token_index + 1..]
425                                .iter()
426                                .find_map(|token| match token {
427                                    Token::Word(word) => font.map(|f| {
428                                        TextLayout::compute(
429                                            &self.res,
430                                            f,
431                                            &format!("{word} "),
432                                            None,
433                                            0,
434                                        )
435                                        .width
436                                    }),
437                                    _ => None,
438                                })
439                                .unwrap_or(doc.indent);
440                            row.y += font.map_or(0, |f| self.res.fonts[f].font.size / 4);
441                            row.left -= marker_width;
442                            row.x = row.left;
443                            row.restore_left = marker_width;
444                        }
445                        ('a', Some('b')) => row.link = (!arg.is_empty()).then_some(arg),
446                        ('a', Some('e')) => row.link = None,
447                        ('i', Some(d)) => {
448                            if let Some(sheet) = d
449                                .to_digit(10)
450                                .and_then(|i| doc.sheets.get(i as usize))
451                                .copied()
452                                .flatten()
453                            {
454                                if let Some(s) = self.res.sheets[sheet].sheet.sprite(&arg) {
455                                    let sprite = SpriteRef {
456                                        sheet,
457                                        width: s.width,
458                                        height: s.height,
459                                        uv: s.uv,
460                                    };
461                                    self.document_item(
462                                        doc,
463                                        &mut row,
464                                        Content::Sprite(sprite),
465                                        [s.width, s.height],
466                                    );
467                                }
468                            }
469                        }
470                        ('t', _) => {
471                            let width = doc.indent;
472                            self.document_item(doc, &mut row, Content::Space, [width, 0]);
473                        }
474                        ('s', _) => {
475                            let width = doc.fonts.get(row.font).copied().flatten().map_or(0, |f| {
476                                TextLayout::compute(&self.res, f, " ", None, 0).width
477                            });
478                            self.document_item(doc, &mut row, Content::Space, [width, 0]);
479                        }
480                        ('d', _) => {
481                            if let Some(capture) = doc.captures.get(&capture_name(&arg)).cloned() {
482                                let size = capture.size;
483                                self.document_item(doc, &mut row, Content::Capture(capture), size);
484                            } else {
485                                log::warn!("document capture {arg:?} was not found in its image collection");
486                            }
487                        }
488                        _ => {}
489                    }
490                }
491            }
492        }
493        if row.start < doc.items.len() {
494            self.document_end_row(doc, &mut row);
495        }
496        doc.size = [doc.width.max(1), row.y];
497        for item in &doc.items {
498            if let Some(target) = &item.link {
499                doc.links.push((
500                    target.clone(),
501                    [
502                        item.pos[0],
503                        item.pos[1],
504                        item.pos[0] + item.size[0],
505                        item.pos[1] + item.size[1],
506                    ],
507                ));
508            }
509        }
510    }
511
512    fn document_item(&self, doc: &mut Document, row: &mut Row, content: Content, size: [i32; 2]) {
513        if row.start < doc.items.len() && row.x + size[0] > row.right {
514            self.document_end_row(doc, row);
515        }
516        let color = if row.link.is_some() {
517            doc.link_colors[0]
518        } else {
519            doc.colors.get(row.color).copied().unwrap_or(0xFFFF_FFFF)
520        };
521        doc.items.push(Item {
522            content,
523            pos: [row.x, row.y],
524            size,
525            color,
526            link: row.link.clone(),
527        });
528        row.x += size[0];
529    }
530
531    fn document_end_row(&self, doc: &mut Document, row: &mut Row) {
532        let default_height = doc
533            .fonts
534            .get(row.font)
535            .copied()
536            .flatten()
537            .map_or(0, |f| self.res.fonts[f].font.size);
538        let height = doc.items[row.start..]
539            .iter()
540            .map(|i| i.size[1])
541            .max()
542            .unwrap_or(default_height);
543        let width = row.x - row.left;
544        let shift = match row.align {
545            'r' => row.right - row.left - width,
546            'c' => (row.right - row.left - width) / 2,
547            _ => 0,
548        };
549        for item in &mut doc.items[row.start..] {
550            item.pos[0] += shift;
551            item.pos[1] = row.y
552                + match row.vertical {
553                    't' => 0,
554                    'c' => (height - item.size[1]) / 2,
555                    _ => height - item.size[1],
556                };
557        }
558        row.y += height;
559        row.start = doc.items.len();
560        row.left += row.restore_left;
561        row.restore_left = 0;
562        row.x = row.left;
563    }
564
565    pub(crate) fn draw_document(
566        &self,
567        doc: &Document,
568        origin: [i32; 2],
569        color: u32,
570        clip: Option<[i32; 4]>,
571        out: &mut Vec<Quad>,
572    ) {
573        let hovered = doc
574            .hovered_link
575            .and_then(|i| doc.links.get(i))
576            .map(|(target, _)| target);
577        let pressed = doc
578            .pressed_link
579            .and_then(|i| doc.links.get(i))
580            .map(|(target, _)| target);
581        for item in &doc.items {
582            let pos = [origin[0] + item.pos[0], origin[1] + item.pos[1]];
583            let hot = item
584                .link
585                .as_ref()
586                .is_some_and(|target| Some(target) == hovered);
587            let ink = if hot {
588                doc.link_colors[if pressed == hovered { 2 } else { 1 }]
589            } else {
590                item.color
591            };
592            let tint = modulate(color, ink);
593            match &item.content {
594                Content::Text(text) => self.draw_text(text, pos, tint, clip, out),
595                Content::Sprite(sprite) => Self::sprite_quad(
596                    out,
597                    sprite,
598                    [
599                        pos[0],
600                        pos[1],
601                        pos[0] + sprite.width,
602                        pos[1] + sprite.height,
603                    ],
604                    tint,
605                    clip,
606                ),
607                Content::Capture(capture) => {
608                    let mut capture_clip = clip;
609                    for part in &capture.parts {
610                        let rect = [
611                            part.rect[0] + pos[0],
612                            part.rect[1] + pos[1],
613                            part.rect[2] + pos[0],
614                            part.rect[3] + pos[1],
615                        ];
616                        if part.sheet == -1 {
617                            capture_clip = intersect(clip, rect);
618                        } else if part.sheet == -2 {
619                            capture_clip = clip;
620                        } else if let Some(sprite) = part.sprite {
621                            Self::sprite_quad(
622                                out,
623                                &sprite,
624                                rect,
625                                modulate(color, part.color),
626                                capture_clip,
627                            );
628                        }
629                    }
630                }
631                Content::Space => {}
632            }
633            if hot {
634                if let Some(sprite) = doc.underline {
635                    let y = pos[1] + item.size[1] - 1;
636                    Self::sprite_quad(
637                        out,
638                        &sprite,
639                        [pos[0], y, pos[0] + item.size[0], y + 1],
640                        tint,
641                        clip,
642                    );
643                }
644            }
645        }
646    }
647}
648
649#[cfg(test)]
650mod tests;