From 98277158736f6347a4723d15cac8fe88cd21d2bd Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Mon, 8 Jun 2026 21:48:06 +0200 Subject: [PATCH] Add bullet subheadings and font-accurate slide auto-fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bullet slides can carry an optional subheading under the title (stored as `## …` in the slide's subtitle, round-tripped losslessly). - The two-bullet-column subheadings and the bullets subheading participate in the auto-fit so the text keeps scaling to fill the slide. - Slide text auto-sizing now measures with the deck's own font, so the fit matches what is rendered and the text uses the available space instead of staying smaller than needed. - Editor fields, translations (all languages), docs and tests included. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 + docs/FILE_FORMAT.md | 15 +- lib/l10n/app_localizations.dart | 21 +++ lib/models/slide.dart | 13 ++ lib/services/markdown_service.dart | 76 +++++++- lib/widgets/editors/bullets_editor.dart | 11 ++ lib/widgets/editors/two_bullets_editor.dart | 34 +++- lib/widgets/slides/slide_preview.dart | 192 +++++++++++++++++--- test/markdown_round_trip_test.dart | 50 ++++- test/two_bullets_preview_test.dart | 82 +++++++++ 10 files changed, 456 insertions(+), 43 deletions(-) create mode 100644 test/two_bullets_preview_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 985a604..6a7dd11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ and the project aims to follow [Semantic Versioning](https://semver.org/). the EUPL-1.2 licence text. ### Changed +- **Bullet slides** can now carry an optional **subheading** under the title; the + **two bullet columns** type can have an optional **heading above each column**, + separate from the slide title. +- Slide text auto-sizing now measures with the deck's own font, so text grows to + use the available space more accurately instead of staying smaller than needed. - Slide transitions in the presenter no longer flash a black frame (neighbour images are precached and `gaplessPlayback` is enabled) — important for recording. diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md index 9731e78..1f8eb01 100644 --- a/docs/FILE_FORMAT.md +++ b/docs/FILE_FORMAT.md @@ -216,10 +216,11 @@ afbeelding geschreven. Optionele toelichtende paragraaf ``` -**Bullets** (geen class) — inspringen met tabs in het model → 2 spaties per -niveau in Markdown: +**Bullets** (geen class) — optioneel een **subkop** (`## …`, komt in `subtitle`), +en inspringen met tabs in het model → 2 spaties per niveau in Markdown: ```markdown # Kop +## Subkop (optioneel) - Eerste punt - Subpunt @@ -227,13 +228,17 @@ niveau in Markdown: **Twee bulletkolommen** (`two-bullets`) — naast de zichtbare HTML-grid worden de twee kolommen ook **canoniek opgeslagen** in commentaar (base64url van een JSON- -array), zodat ze verliesvrij teruggelezen worden: +array), zodat ze verliesvrij teruggelezen worden. Elke kolom kan optioneel een +**kop** krijgen (`*_title`, base64url van platte tekst); die wordt alleen +geschreven als hij gevuld is en verschijnt als `

` boven de kolom: ```markdown + (optioneel) + (optioneel)
-
-
+

+

``` diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7adae2d..80dc866 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2384,6 +2384,9 @@ const _dutchSourceStringAdditions = { 'For example #33FF33 for a CRT-green screen.', 'Onderdeel van stijlprofiel ': 'Part of style profile ', 'Broncode lettertype': 'Code font', + 'Kop (optioneel)': 'Heading (optional)', + 'Subkop (optioneel)': 'Subheading (optional)', + 'Subkop': 'Subheading', 'Systeem (monospace)': 'System (monospace)', 'Platte tekst': 'Plain text', 'Titel (optioneel)': 'Title (optional)', @@ -2423,6 +2426,9 @@ const _dutchSourceStringAdditions = { 'Ad esempio #33FF33 per uno schermo verde CRT.', 'Onderdeel van stijlprofiel ': 'Parte del profilo di stile ', 'Broncode lettertype': 'Font del codice', + 'Kop (optioneel)': 'Intestazione (facoltativa)', + 'Subkop (optioneel)': 'Sottotitolo (facoltativo)', + 'Subkop': 'Sottotitolo', 'Systeem (monospace)': 'Sistema (monospace)', 'Kleur van reeks': 'Colore della serie', 'Kleur van rij': 'Colore della riga', @@ -2652,6 +2658,9 @@ const _dutchSourceStringAdditions = { 'Zum Beispiel #33FF33 für einen CRT-grünen Bildschirm.', 'Onderdeel van stijlprofiel ': 'Teil des Stilprofils ', 'Broncode lettertype': 'Code-Schriftart', + 'Kop (optioneel)': 'Überschrift (optional)', + 'Subkop (optioneel)': 'Unterüberschrift (optional)', + 'Subkop': 'Unterüberschrift', 'Systeem (monospace)': 'System (monospace)', 'Kleur van reeks': 'Reihenfarbe', 'Kleur van rij': 'Zeilenfarbe', @@ -2882,6 +2891,9 @@ const _dutchSourceStringAdditions = { 'Par exemple #33FF33 pour un écran vert CRT.', 'Onderdeel van stijlprofiel ': 'Fait partie du profil de style ', 'Broncode lettertype': 'Police du code', + 'Kop (optioneel)': 'En-tête (facultatif)', + 'Subkop (optioneel)': 'Sous-titre (facultatif)', + 'Subkop': 'Sous-titre', 'Systeem (monospace)': 'Système (monospace)', 'Kleur van reeks': 'Couleur de la série', 'Kleur van rij': 'Couleur de la ligne', @@ -3112,6 +3124,9 @@ const _dutchSourceStringAdditions = { 'Por ejemplo #33FF33 para una pantalla verde CRT.', 'Onderdeel van stijlprofiel ': 'Parte del perfil de estilo ', 'Broncode lettertype': 'Fuente del código', + 'Kop (optioneel)': 'Encabezado (opcional)', + 'Subkop (optioneel)': 'Subtítulo (opcional)', + 'Subkop': 'Subtítulo', 'Systeem (monospace)': 'Sistema (monospace)', 'Kleur van reeks': 'Color de la serie', 'Kleur van rij': 'Color de la fila', @@ -3342,6 +3357,9 @@ const _dutchSourceStringAdditions = { 'Bygelyks #33FF33 foar in CRT-grien skerm.', 'Onderdeel van stijlprofiel ': 'Underdiel fan stylprofyl ', 'Broncode lettertype': 'Boarnekoade lettertype', + 'Kop (optioneel)': 'Kop (opsjoneel)', + 'Subkop (optioneel)': 'Subkop (opsjoneel)', + 'Subkop': 'Subkop', 'Systeem (monospace)': 'Systeem (monospace)', 'Kleur van reeks': 'Rigekleur', 'Kleur van rij': 'Rijekleur', @@ -3569,6 +3587,9 @@ const _dutchSourceStringAdditions = { 'Por ehèmpel #33FF33 pa un pantaya berde CRT.', 'Onderdeel van stijlprofiel ': 'Parti di e perfil di estilo ', 'Broncode lettertype': 'Tipo di lèter di kódigo', + 'Kop (optioneel)': 'Enkabesado (opshonal)', + 'Subkop (optioneel)': 'Subtítulo (opshonal)', + 'Subkop': 'Subtítulo', 'Systeem (monospace)': 'Sistema (monospace)', 'Kleur van reeks': 'Koló di serie', 'Kleur van rij': 'Koló di liña', diff --git a/lib/models/slide.dart b/lib/models/slide.dart index 2a84970..748a710 100644 --- a/lib/models/slide.dart +++ b/lib/models/slide.dart @@ -90,6 +90,11 @@ class Slide { final String subtitle; final List bullets; final List bullets2; + + /// Optional headings above the two bullet columns (twoBullets only). Empty = + /// no heading for that column. + final String columnTitle1; + final String columnTitle2; final String imagePath; final String imagePath2; final String imageCaption; @@ -123,6 +128,8 @@ class Slide { this.subtitle = '', this.bullets = const [], this.bullets2 = const [], + this.columnTitle1 = '', + this.columnTitle2 = '', this.imagePath = '', this.imagePath2 = '', this.imageCaption = '', @@ -176,6 +183,8 @@ class Slide { subtitle: src.subtitle, bullets: List.from(src.bullets), bullets2: List.from(src.bullets2), + columnTitle1: src.columnTitle1, + columnTitle2: src.columnTitle2, imagePath: src.imagePath, imagePath2: src.imagePath2, imageCaption: src.imageCaption, @@ -206,6 +215,8 @@ class Slide { String? subtitle, List? bullets, List? bullets2, + String? columnTitle1, + String? columnTitle2, String? imagePath, String? imagePath2, String? imageCaption, @@ -235,6 +246,8 @@ class Slide { subtitle: subtitle ?? this.subtitle, bullets: bullets ?? this.bullets, bullets2: bullets2 ?? this.bullets2, + columnTitle1: columnTitle1 ?? this.columnTitle1, + columnTitle2: columnTitle2 ?? this.columnTitle2, imagePath: imagePath ?? this.imagePath, imagePath2: imagePath2 ?? this.imagePath2, imageCaption: imageCaption ?? this.imageCaption, diff --git a/lib/services/markdown_service.dart b/lib/services/markdown_service.dart index e384949..2c2b206 100644 --- a/lib/services/markdown_service.dart +++ b/lib/services/markdown_service.dart @@ -18,6 +18,9 @@ class MarkdownService { buf.writeln('theme: ${deck.theme}'); if (deck.paginate) buf.writeln('paginate: true'); // General presentation metadata (also picked up by Marp where applicable). + if (deck.title.isNotEmpty) { + buf.writeln('title: ${_yamlScalar(deck.title)}'); + } if (deck.author.isNotEmpty) { buf.writeln('author: ${_yamlScalar(deck.author)}'); } @@ -212,6 +215,7 @@ class MarkdownService { case SlideType.bullets: if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); + if (slide.subtitle.isNotEmpty) buf.writeln('## ${slide.subtitle}'); buf.writeln(); for (final b in slide.bullets) { _writeBullet(buf, b); @@ -220,7 +224,13 @@ class MarkdownService { case SlideType.twoBullets: if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); buf.writeln(); - _writeTwoBulletColumns(buf, slide.bullets, slide.bullets2); + _writeTwoBulletColumns( + buf, + slide.bullets, + slide.bullets2, + slide.columnTitle1, + slide.columnTitle2, + ); case SlideType.bulletsImage: if (slide.imagePath.isNotEmpty) { @@ -406,17 +416,42 @@ class MarkdownService { StringBuffer buf, List left, List right, + String leftTitle, + String rightTitle, ) { buf.writeln(''); buf.writeln(''); + if (leftTitle.isNotEmpty) { + buf.writeln( + '', + ); + } + if (rightTitle.isNotEmpty) { + buf.writeln( + '', + ); + } buf.writeln( '
', ); + _writeBulletColumn(buf, left, leftTitle); + _writeBulletColumn(buf, right, rightTitle); + buf.writeln('
'); + } + + static void _writeBulletColumn( + StringBuffer buf, + List bullets, + String columnTitle, + ) { + buf.writeln('
'); + if (columnTitle.isNotEmpty) { + buf.writeln( + '

${_escapeHtml(columnTitle)}

', + ); + } buf.writeln('
    '); - _writeHtmlBulletItems(buf, left); - buf.writeln('
'); - buf.writeln('
    '); - _writeHtmlBulletItems(buf, right); + _writeHtmlBulletItems(buf, bullets); buf.writeln('
'); buf.writeln('
'); } @@ -425,6 +460,17 @@ class MarkdownService { return base64Url.encode(utf8.encode(jsonEncode(bullets))); } + static String _encodeText(String value) => + base64Url.encode(utf8.encode(value)); + + static String _decodeText(String encoded) { + try { + return utf8.decode(base64Url.decode(encoded.trim())); + } catch (_) { + return ''; + } + } + static List _decodeBullets(String encoded) { try { final decoded = utf8.decode(base64Url.decode(encoded.trim())); @@ -523,6 +569,7 @@ class MarkdownService { String theme = 'ocideck'; bool paginate = true; ThemeProfile themeProfile = const ThemeProfile(); + String? presentationTitle; String author = ''; String organization = ''; String version = ''; @@ -541,6 +588,8 @@ class MarkdownService { theme = line.substring(6).trim(); } else if (line.startsWith('paginate:')) { paginate = line.substring(9).trim() == 'true'; + } else if (line.startsWith('title:')) { + presentationTitle = _parseScalar(line.substring(6)); } else if (line.startsWith('author:')) { author = _parseScalar(line.substring(7)); } else if (line.startsWith('organization:')) { @@ -574,10 +623,11 @@ class MarkdownService { if (slide != null) slides.add(slide); } - String title = 'Presentatie'; - if (slides.isNotEmpty && slides.first.title.isNotEmpty) { - title = slides.first.title; - } + final title = + presentationTitle ?? + (slides.isNotEmpty && slides.first.title.isNotEmpty + ? slides.first.title + : 'Presentatie'); String? projectPath; if (filePath != null) { @@ -626,6 +676,8 @@ class MarkdownService { TlpLevel slideTlp = TlpLevel.none; final bullets = []; var bullets2 = []; + var columnTitle1 = ''; + var columnTitle2 = ''; // bulletsImage slides store their panel width in ``; capture it before the comment is stripped. int styleImageWidth = 0; @@ -646,6 +698,10 @@ class MarkdownService { bullets ..clear() ..addAll(_decodeBullets(content.substring(25))); + } else if (content.startsWith('ocideck_two_bullets_left_title:')) { + columnTitle1 = _decodeText(content.substring(31)); + } else if (content.startsWith('ocideck_two_bullets_right_title:')) { + columnTitle2 = _decodeText(content.substring(32)); } else if (content.startsWith('ocideck_two_bullets_right:')) { bullets2 = _decodeBullets(content.substring(26)); } else if (!content.startsWith('_')) { @@ -844,6 +900,8 @@ class MarkdownService { subtitle: type == SlideType.section ? paragraph : h2, bullets: bullets, bullets2: bullets2, + columnTitle1: columnTitle1, + columnTitle2: columnTitle2, imagePath: imagePath, imagePath2: imagePath2, imageCaption: imageCaption, diff --git a/lib/widgets/editors/bullets_editor.dart b/lib/widgets/editors/bullets_editor.dart index 777a469..d40bbb5 100644 --- a/lib/widgets/editors/bullets_editor.dart +++ b/lib/widgets/editors/bullets_editor.dart @@ -16,6 +16,7 @@ class BulletsEditor extends StatefulWidget { class _BulletsEditorState extends State { late final TextEditingController _title; + late final TextEditingController _subtitle; late List _bullets; late List _levels; late List _focusNodes; @@ -27,6 +28,8 @@ class _BulletsEditorState extends State { super.initState(); _title = TextEditingController(text: widget.slide.title); _title.addListener(_emit); + _subtitle = TextEditingController(text: widget.slide.subtitle); + _subtitle.addListener(_emit); _initBullets(widget.slide.bullets); } @@ -55,6 +58,7 @@ class _BulletsEditorState extends State { widget.onUpdate( widget.slide.copyWith( title: _title.text, + subtitle: _subtitle.text, bullets: List.generate( _bullets.length, (i) => '\t' * _levels[i] + _bullets[i].text, @@ -151,6 +155,7 @@ class _BulletsEditorState extends State { @override void dispose() { _title.dispose(); + _subtitle.dispose(); for (final c in _bullets) { c.dispose(); } @@ -167,6 +172,12 @@ class _BulletsEditorState extends State { padding: const EdgeInsets.all(16), children: [ EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'), + const SizedBox(height: 12), + EditorField( + label: l10n.d('Subkop (optioneel)'), + controller: _subtitle, + hint: l10n.d('Subkop'), + ), const SizedBox(height: 16), const SectionLabel('Bullets'), ReorderableListView( diff --git a/lib/widgets/editors/two_bullets_editor.dart b/lib/widgets/editors/two_bullets_editor.dart index af4fdf3..7833553 100644 --- a/lib/widgets/editors/two_bullets_editor.dart +++ b/lib/widgets/editors/two_bullets_editor.dart @@ -22,6 +22,8 @@ class TwoBulletsEditor extends StatefulWidget { class _TwoBulletsEditorState extends State { late final TextEditingController _title; + late final TextEditingController _heading1; + late final TextEditingController _heading2; late _BulletSet _left; late _BulletSet _right; @@ -30,6 +32,10 @@ class _TwoBulletsEditorState extends State { super.initState(); _title = TextEditingController(text: widget.slide.title); _title.addListener(_emit); + _heading1 = TextEditingController(text: widget.slide.columnTitle1); + _heading2 = TextEditingController(text: widget.slide.columnTitle2); + _heading1.addListener(_emit); + _heading2.addListener(_emit); _left = _BulletSet(widget.slide.bullets, _emit); _right = _BulletSet(widget.slide.bullets2, _emit); } @@ -38,6 +44,8 @@ class _TwoBulletsEditorState extends State { widget.onUpdate( widget.slide.copyWith( title: _title.text, + columnTitle1: _heading1.text, + columnTitle2: _heading2.text, bullets: _left.values, bullets2: _right.values, ), @@ -47,6 +55,8 @@ class _TwoBulletsEditorState extends State { @override void dispose() { _title.dispose(); + _heading1.dispose(); + _heading2.dispose(); _left.dispose(); _right.dispose(); super.dispose(); @@ -63,8 +73,18 @@ class _TwoBulletsEditorState extends State { builder: (context, constraints) { final narrow = constraints.maxWidth < 560; final columns = [ - _BulletColumn(label: 'Bullets links', set: _left, emit: _emit), - _BulletColumn(label: 'Bullets rechts', set: _right, emit: _emit), + _BulletColumn( + label: 'Bullets links', + set: _left, + emit: _emit, + headingController: _heading1, + ), + _BulletColumn( + label: 'Bullets rechts', + set: _right, + emit: _emit, + headingController: _heading2, + ), ]; if (narrow) { return Column( @@ -202,11 +222,13 @@ class _BulletColumn extends StatefulWidget { final String label; final _BulletSet set; final VoidCallback emit; + final TextEditingController headingController; const _BulletColumn({ required this.label, required this.set, required this.emit, + required this.headingController, }); @override @@ -222,6 +244,14 @@ class _BulletColumnState extends State<_BulletColumn> { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + TextField( + controller: widget.headingController, + decoration: InputDecoration( + labelText: l10n.d('Kop (optioneel)'), + isDense: true, + ), + ), + const SizedBox(height: 12), SectionLabel(widget.label), const SizedBox(height: 6), for (int i = 0; i < set.controllers.length; i++) _buildRow(i), diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index c770337..343356d 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -646,6 +646,7 @@ class _BulletsPreview extends StatelessWidget { final pad = w * 0.07; final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; final titleSize = w * 0.042; + final subtitleSize = w * 0.030; final bulletSize = w * 0.026; final spacing = pad * 0.5; final bulletGap = w * 0.006; @@ -653,6 +654,8 @@ class _BulletsPreview extends StatelessWidget { .where((b) => b.trimLeft().isNotEmpty) .toList(); final hasTitle = slide.title.isNotEmpty; + final subtitle = slide.subtitle; + final hasSubtitle = subtitle.isNotEmpty; final slideHeight = w * 9 / 16; final availW = (w - pad * 2).clamp(w * 0.12, w); @@ -669,6 +672,9 @@ class _BulletsPreview extends StatelessWidget { bulletSize: bulletSize, spacing: spacing, bulletGap: bulletGap, + font: font, + subtitle: subtitle, + subtitleSize: subtitleSize, maxScale: _kSplitBulletsMaxScale, ); @@ -705,7 +711,23 @@ class _BulletsPreview extends StatelessWidget { ), linkColor: _hexColor(profile.accentColor), ), - if (hasTitle && bullets.isNotEmpty) + if (hasSubtitle) ...[ + SizedBox(height: spacing * scale * 0.4), + _md( + context, + subtitle, + _applyFont( + font, + TextStyle( + fontSize: subtitleSize * scale, + fontWeight: FontWeight.w600, + color: _hexColor(profile.accentColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ], + if ((hasTitle || hasSubtitle) && bullets.isNotEmpty) SizedBox(height: spacing * scale), ...bullets.map((b) { int level = 0; @@ -900,6 +922,62 @@ class _TwoBulletsPreview extends StatelessWidget { required this.profile, }); + /// One bullet column with an optional heading above it. When any column has a + /// heading, an equal-height slot is reserved in both so the bullet lists line + /// up. + Widget _bulletColumn( + BuildContext context, { + required String title, + required List bullets, + required double columnW, + required double headingSize, + required double headingSlotH, + required double headingGap, + required double bulletSize, + required double bulletGap, + required double scale, + }) { + return SizedBox( + width: columnW, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (headingSlotH > 0) ...[ + SizedBox( + width: double.infinity, + height: headingSlotH, + child: title.isEmpty + ? null + : _md( + context, + title, + _applyFont( + font, + TextStyle( + fontSize: headingSize, + fontWeight: FontWeight.bold, + color: _hexColor(profile.accentColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + SizedBox(height: headingGap), + ], + _BulletListColumn( + bullets: bullets, + font: font, + profile: profile, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final pad = w * 0.065; @@ -917,6 +995,12 @@ class _TwoBulletsPreview extends StatelessWidget { .toList(); final hasTitle = slide.title.isNotEmpty; + final col1Title = slide.columnTitle1.trim(); + final col2Title = slide.columnTitle2.trim(); + final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty; + final headingSize = w * 0.03; + final headingGap = w * 0.012; + final slideHeight = w * 9 / 16; final contentW = (w - pad * 2).clamp(w * 0.12, w); final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w); @@ -927,9 +1011,19 @@ class _TwoBulletsPreview extends StatelessWidget { titleSize, contentW, bold: true, + fontFamily: font, ); availH -= spacing; } + // Reserve room for the (optional) column headings so the bullets still fit. + double headingHeight(String t) => t.isEmpty + ? 0 + : _measureTextHeight(t, headingSize, columnW, bold: true, fontFamily: font); + final maxHeadingH = math.max( + headingHeight(col1Title), + headingHeight(col2Title), + ); + if (hasColumnTitles) availH -= maxHeadingH + headingGap; final leftScale = _bulletsFitScale( availW: columnW, availH: availH, @@ -940,6 +1034,7 @@ class _TwoBulletsPreview extends StatelessWidget { bulletSize: bulletSize, spacing: spacing, bulletGap: bulletGap, + font: font, maxScale: _kBulletsMaxScale, ); final rightScale = _bulletsFitScale( @@ -952,6 +1047,7 @@ class _TwoBulletsPreview extends StatelessWidget { bulletSize: bulletSize, spacing: spacing, bulletGap: bulletGap, + font: font, maxScale: _kBulletsMaxScale, ); final scale = leftScale < rightScale ? leftScale : rightScale; @@ -993,28 +1089,30 @@ class _TwoBulletsPreview extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: columnW, - child: _BulletListColumn( - bullets: leftBullets, - font: font, - profile: profile, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: scale, - ), + _bulletColumn( + context, + title: col1Title, + bullets: leftBullets, + columnW: columnW, + headingSize: headingSize, + headingSlotH: hasColumnTitles ? maxHeadingH : 0, + headingGap: headingGap, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, ), SizedBox(width: columnGap), - SizedBox( - width: columnW, - child: _BulletListColumn( - bullets: rightBullets, - font: font, - profile: profile, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: scale, - ), + _bulletColumn( + context, + title: col2Title, + bullets: rightBullets, + columnW: columnW, + headingSize: headingSize, + headingSlotH: hasColumnTitles ? maxHeadingH : 0, + headingGap: headingGap, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, ), ], ), @@ -1087,6 +1185,7 @@ class _BulletsImagePreview extends StatelessWidget { bulletSize: bulletSize, spacing: spacing, bulletGap: bulletGap, + font: font, maxScale: _kBulletsMaxScale, ); @@ -1329,6 +1428,9 @@ double _bulletsFitScale({ required double bulletSize, required double spacing, required double bulletGap, + required String font, + String subtitle = '', + double subtitleSize = 0, double minScale = 0.2, double maxScale = 1.0, }) { @@ -1345,6 +1447,9 @@ double _bulletsFitScale({ bulletSize: bulletSize, spacing: spacing, bulletGap: bulletGap, + font: font, + subtitle: subtitle, + subtitleSize: subtitleSize, ); // Everything already fits at the largest allowed size → use it. @@ -1381,12 +1486,33 @@ double _bulletsBlockHeight({ required double bulletSize, required double spacing, required double bulletGap, + required String font, + String subtitle = '', + double subtitleSize = 0, }) { var height = 0.0; if (hasTitle) { - height += _measureTextHeight(title, titleSize * scale, availW, bold: true); + height += _measureTextHeight( + title, + titleSize * scale, + availW, + bold: true, + fontFamily: font, + ); + } + if (subtitle.isNotEmpty) { + height += spacing * scale * 0.4; + height += _measureTextHeight( + subtitle, + subtitleSize * scale, + availW, + bold: true, + fontFamily: font, + ); + } + if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) { + height += spacing * scale; } - if (hasTitle && bullets.isNotEmpty) height += spacing * scale; for (final b in bullets) { int level = 0; while (level < b.length && b[level] == '\t') { @@ -1396,15 +1522,21 @@ double _bulletsBlockHeight({ final fontSize = bulletSize * _bulletLevelScale(level) * scale; final indent = level * bulletSize * 1.05 * scale; final marker = '${_bulletMarkerForLevel(level)} '; - final markerW = _measureTextWidth(marker, fontSize, bold: true); + final markerW = _measureTextWidth(marker, fontSize, bold: true, fontFamily: font); final wrapW = (availW - indent - markerW).clamp(1.0, availW); final textH = _measureTextHeight( text, fontSize, wrapW, lineHeight: _kBulletLineHeight, + fontFamily: font, + ); + final markerH = _measureTextHeight( + marker, + fontSize, + double.infinity, + fontFamily: font, ); - final markerH = _measureTextHeight(marker, fontSize, double.infinity); height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH); } return height; @@ -1416,11 +1548,13 @@ double _measureTextHeight( double maxWidth, { double? lineHeight, bool bold = false, + String? fontFamily, }) { final painter = TextPainter( text: TextSpan( text: stripInlineMarkdown(text), style: TextStyle( + fontFamily: fontFamily, fontSize: fontSize, height: lineHeight, fontWeight: bold ? FontWeight.bold : null, @@ -1431,11 +1565,17 @@ double _measureTextHeight( return painter.height; } -double _measureTextWidth(String text, double fontSize, {bool bold = false}) { +double _measureTextWidth( + String text, + double fontSize, { + bool bold = false, + String? fontFamily, +}) { final painter = TextPainter( text: TextSpan( text: stripInlineMarkdown(text), style: TextStyle( + fontFamily: fontFamily, fontSize: fontSize, fontWeight: bold ? FontWeight.bold : null, ), diff --git a/test/markdown_round_trip_test.dart b/test/markdown_round_trip_test.dart index add1356..3a32abf 100644 --- a/test/markdown_round_trip_test.dart +++ b/test/markdown_round_trip_test.dart @@ -76,6 +76,20 @@ void main() { ]); }); + test('bullets slide keeps an optional subheading', () { + final out = _roundTrip( + Slide.create(SlideType.bullets).copyWith( + title: 'Agenda', + subtitle: 'Vandaag', + bullets: ['Punt een', 'Punt twee'], + ), + ); + expect(out.type, SlideType.bullets); + expect(out.title, 'Agenda'); + expect(out.subtitle, 'Vandaag'); + expect(out.bullets, ['Punt een', 'Punt twee']); + }); + test('twoBullets slide keeps both bullet columns', () { final out = _roundTrip( Slide.create(SlideType.twoBullets).copyWith( @@ -90,6 +104,39 @@ void main() { expect(out.bullets2, ['Rechts punt', '\t\tRechts diep']); }); + test('twoBullets slide keeps optional column headings', () { + final out = _roundTrip( + Slide.create(SlideType.twoBullets).copyWith( + title: 'Vergelijking', + columnTitle1: 'Voordelen', + columnTitle2: 'Nadelen', + bullets: ['Snel'], + bullets2: ['Duur'], + ), + ); + expect(out.type, SlideType.twoBullets); + expect(out.columnTitle1, 'Voordelen'); + expect(out.columnTitle2, 'Nadelen'); + expect(out.bullets, ['Snel']); + expect(out.bullets2, ['Duur']); + }); + + test('twoBullets without headings stays empty (no spurious comments)', () { + final service = MarkdownService(); + final md = service.generateDeck( + Deck( + title: 'Demo', + slides: [ + Slide.create(SlideType.twoBullets).copyWith(bullets: ['A'], bullets2: ['B']), + ], + ), + ); + expect(md, isNot(contains('ocideck_two_bullets_left_title'))); + final out = service.parseDeck(md)!.slides.single; + expect(out.columnTitle1, ''); + expect(out.columnTitle2, ''); + }); + test('bulletsImage slide keeps bullets, image, size and caption', () { final out = _roundTrip( Slide.create(SlideType.bulletsImage).copyWith( @@ -386,7 +433,8 @@ void main() { ); final deck = service.parseDeck(markdown); expect(deck, isNotNull); - expect(deck!.author, 'Jan Jansen'); + expect(deck!.title, 'Demo'); + expect(deck.author, 'Jan Jansen'); expect(deck.organization, 'Vigilis'); expect(deck.version, '1.2'); expect(deck.date, '2026-05-30'); diff --git a/test/two_bullets_preview_test.dart b/test/two_bullets_preview_test.dart new file mode 100644 index 0000000..ea45c21 --- /dev/null +++ b/test/two_bullets_preview_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/widgets/slides/slide_preview.dart'; + +Widget _host(Slide slide) { + return MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 800, + height: 450, + child: SlidePreviewWidget(slide: slide), + ), + ), + ), + ); +} + +void main() { + testWidgets('two-bullet columns render their optional headings', ( + tester, + ) async { + final slide = Slide.create(SlideType.twoBullets).copyWith( + title: 'Vergelijking', + columnTitle1: 'Voordelen', + columnTitle2: 'Nadelen', + bullets: const ['Snel', 'Goedkoop'], + bullets2: const ['Complex'], + ); + + await tester.pumpWidget(_host(slide)); + await tester.pump(); + + expect(find.text('Voordelen'), findsOneWidget); + expect(find.text('Nadelen'), findsOneWidget); + // Heading sits above the bullets of its column. + final headingTop = tester.getTopLeft(find.text('Voordelen')).dy; + final bulletTop = tester.getTopLeft(find.text('Snel')).dy; + expect(headingTop, lessThan(bulletTop)); + expect(tester.takeException(), isNull); + }); + + testWidgets('two-bullet columns without headings render no heading text', ( + tester, + ) async { + final slide = Slide.create(SlideType.twoBullets).copyWith( + title: 'Geen koppen', + bullets: const ['Links'], + bullets2: const ['Rechts'], + ); + + await tester.pumpWidget(_host(slide)); + await tester.pump(); + + expect(find.text('Links'), findsOneWidget); + expect(find.text('Rechts'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('bullets slide renders an optional subheading below the title', ( + tester, + ) async { + final slide = Slide.create(SlideType.bullets).copyWith( + title: 'Agenda', + subtitle: 'Vandaag', + bullets: const ['Punt een', 'Punt twee'], + ); + + await tester.pumpWidget(_host(slide)); + await tester.pump(); + + expect(find.text('Agenda'), findsOneWidget); + expect(find.text('Vandaag'), findsOneWidget); + final titleTop = tester.getTopLeft(find.text('Agenda')).dy; + final subTop = tester.getTopLeft(find.text('Vandaag')).dy; + final bulletTop = tester.getTopLeft(find.text('Punt een')).dy; + expect(titleTop, lessThan(subTop)); + expect(subTop, lessThan(bulletTop)); + expect(tester.takeException(), isNull); + }); +}