Bullet subheadings, font-accurate auto-fit, and small UX tweaks #3

Merged
brenno merged 2 commits from feature/bullet-subheadings-and-autofit into main 2026-06-08 19:52:51 +00:00
10 changed files with 456 additions and 43 deletions
Showing only changes of commit 9827715873 - Show all commits

View file

@ -42,6 +42,11 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
the EUPL-1.2 licence text. the EUPL-1.2 licence text.
### Changed ### 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 - Slide transitions in the presenter no longer flash a black frame (neighbour
images are precached and `gaplessPlayback` is enabled) — important for images are precached and `gaplessPlayback` is enabled) — important for
recording. recording.

View file

@ -216,10 +216,11 @@ afbeelding geschreven.
Optionele toelichtende paragraaf Optionele toelichtende paragraaf
``` ```
**Bullets** (geen class) — inspringen met tabs in het model → 2 spaties per **Bullets** (geen class) — optioneel een **subkop** (`## …`, komt in `subtitle`),
niveau in Markdown: en inspringen met tabs in het model → 2 spaties per niveau in Markdown:
```markdown ```markdown
# Kop # Kop
## Subkop (optioneel)
- Eerste punt - Eerste punt
- Subpunt - Subpunt
@ -227,13 +228,17 @@ niveau in Markdown:
**Twee bulletkolommen** (`two-bullets`) — naast de zichtbare HTML-grid worden de **Twee bulletkolommen** (`two-bullets`) — naast de zichtbare HTML-grid worden de
twee kolommen ook **canoniek opgeslagen** in commentaar (base64url van een JSON- 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 `<h3>` boven de kolom:
```markdown ```markdown
<!-- ocideck_two_bullets_left: <base64url(JSON[])> --> <!-- ocideck_two_bullets_left: <base64url(JSON[])> -->
<!-- ocideck_two_bullets_right: <base64url(JSON[])> --> <!-- ocideck_two_bullets_right: <base64url(JSON[])> -->
<!-- ocideck_two_bullets_left_title: <base64url(tekst)> --> (optioneel)
<!-- ocideck_two_bullets_right_title: <base64url(tekst)> --> (optioneel)
<div class="ocideck-two-bullets" style="…"> <div class="ocideck-two-bullets" style="…">
<ul></ul> <div><h3></h3><ul></ul></div>
<ul></ul> <div><h3></h3><ul></ul></div>
</div> </div>
``` ```

View file

@ -2384,6 +2384,9 @@ const _dutchSourceStringAdditions = {
'For example #33FF33 for a CRT-green screen.', 'For example #33FF33 for a CRT-green screen.',
'Onderdeel van stijlprofiel ': 'Part of style profile ', 'Onderdeel van stijlprofiel ': 'Part of style profile ',
'Broncode lettertype': 'Code font', 'Broncode lettertype': 'Code font',
'Kop (optioneel)': 'Heading (optional)',
'Subkop (optioneel)': 'Subheading (optional)',
'Subkop': 'Subheading',
'Systeem (monospace)': 'System (monospace)', 'Systeem (monospace)': 'System (monospace)',
'Platte tekst': 'Plain text', 'Platte tekst': 'Plain text',
'Titel (optioneel)': 'Title (optional)', 'Titel (optioneel)': 'Title (optional)',
@ -2423,6 +2426,9 @@ const _dutchSourceStringAdditions = {
'Ad esempio #33FF33 per uno schermo verde CRT.', 'Ad esempio #33FF33 per uno schermo verde CRT.',
'Onderdeel van stijlprofiel ': 'Parte del profilo di stile ', 'Onderdeel van stijlprofiel ': 'Parte del profilo di stile ',
'Broncode lettertype': 'Font del codice', 'Broncode lettertype': 'Font del codice',
'Kop (optioneel)': 'Intestazione (facoltativa)',
'Subkop (optioneel)': 'Sottotitolo (facoltativo)',
'Subkop': 'Sottotitolo',
'Systeem (monospace)': 'Sistema (monospace)', 'Systeem (monospace)': 'Sistema (monospace)',
'Kleur van reeks': 'Colore della serie', 'Kleur van reeks': 'Colore della serie',
'Kleur van rij': 'Colore della riga', 'Kleur van rij': 'Colore della riga',
@ -2652,6 +2658,9 @@ const _dutchSourceStringAdditions = {
'Zum Beispiel #33FF33 für einen CRT-grünen Bildschirm.', 'Zum Beispiel #33FF33 für einen CRT-grünen Bildschirm.',
'Onderdeel van stijlprofiel ': 'Teil des Stilprofils ', 'Onderdeel van stijlprofiel ': 'Teil des Stilprofils ',
'Broncode lettertype': 'Code-Schriftart', 'Broncode lettertype': 'Code-Schriftart',
'Kop (optioneel)': 'Überschrift (optional)',
'Subkop (optioneel)': 'Unterüberschrift (optional)',
'Subkop': 'Unterüberschrift',
'Systeem (monospace)': 'System (monospace)', 'Systeem (monospace)': 'System (monospace)',
'Kleur van reeks': 'Reihenfarbe', 'Kleur van reeks': 'Reihenfarbe',
'Kleur van rij': 'Zeilenfarbe', 'Kleur van rij': 'Zeilenfarbe',
@ -2882,6 +2891,9 @@ const _dutchSourceStringAdditions = {
'Par exemple #33FF33 pour un écran vert CRT.', 'Par exemple #33FF33 pour un écran vert CRT.',
'Onderdeel van stijlprofiel ': 'Fait partie du profil de style ', 'Onderdeel van stijlprofiel ': 'Fait partie du profil de style ',
'Broncode lettertype': 'Police du code', 'Broncode lettertype': 'Police du code',
'Kop (optioneel)': 'En-tête (facultatif)',
'Subkop (optioneel)': 'Sous-titre (facultatif)',
'Subkop': 'Sous-titre',
'Systeem (monospace)': 'Système (monospace)', 'Systeem (monospace)': 'Système (monospace)',
'Kleur van reeks': 'Couleur de la série', 'Kleur van reeks': 'Couleur de la série',
'Kleur van rij': 'Couleur de la ligne', 'Kleur van rij': 'Couleur de la ligne',
@ -3112,6 +3124,9 @@ const _dutchSourceStringAdditions = {
'Por ejemplo #33FF33 para una pantalla verde CRT.', 'Por ejemplo #33FF33 para una pantalla verde CRT.',
'Onderdeel van stijlprofiel ': 'Parte del perfil de estilo ', 'Onderdeel van stijlprofiel ': 'Parte del perfil de estilo ',
'Broncode lettertype': 'Fuente del código', 'Broncode lettertype': 'Fuente del código',
'Kop (optioneel)': 'Encabezado (opcional)',
'Subkop (optioneel)': 'Subtítulo (opcional)',
'Subkop': 'Subtítulo',
'Systeem (monospace)': 'Sistema (monospace)', 'Systeem (monospace)': 'Sistema (monospace)',
'Kleur van reeks': 'Color de la serie', 'Kleur van reeks': 'Color de la serie',
'Kleur van rij': 'Color de la fila', 'Kleur van rij': 'Color de la fila',
@ -3342,6 +3357,9 @@ const _dutchSourceStringAdditions = {
'Bygelyks #33FF33 foar in CRT-grien skerm.', 'Bygelyks #33FF33 foar in CRT-grien skerm.',
'Onderdeel van stijlprofiel ': 'Underdiel fan stylprofyl ', 'Onderdeel van stijlprofiel ': 'Underdiel fan stylprofyl ',
'Broncode lettertype': 'Boarnekoade lettertype', 'Broncode lettertype': 'Boarnekoade lettertype',
'Kop (optioneel)': 'Kop (opsjoneel)',
'Subkop (optioneel)': 'Subkop (opsjoneel)',
'Subkop': 'Subkop',
'Systeem (monospace)': 'Systeem (monospace)', 'Systeem (monospace)': 'Systeem (monospace)',
'Kleur van reeks': 'Rigekleur', 'Kleur van reeks': 'Rigekleur',
'Kleur van rij': 'Rijekleur', 'Kleur van rij': 'Rijekleur',
@ -3569,6 +3587,9 @@ const _dutchSourceStringAdditions = {
'Por ehèmpel #33FF33 pa un pantaya berde CRT.', 'Por ehèmpel #33FF33 pa un pantaya berde CRT.',
'Onderdeel van stijlprofiel ': 'Parti di e perfil di estilo ', 'Onderdeel van stijlprofiel ': 'Parti di e perfil di estilo ',
'Broncode lettertype': 'Tipo di lèter di kódigo', '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)', 'Systeem (monospace)': 'Sistema (monospace)',
'Kleur van reeks': 'Koló di serie', 'Kleur van reeks': 'Koló di serie',
'Kleur van rij': 'Koló di liña', 'Kleur van rij': 'Koló di liña',

View file

@ -90,6 +90,11 @@ class Slide {
final String subtitle; final String subtitle;
final List<String> bullets; final List<String> bullets;
final List<String> bullets2; final List<String> 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 imagePath;
final String imagePath2; final String imagePath2;
final String imageCaption; final String imageCaption;
@ -123,6 +128,8 @@ class Slide {
this.subtitle = '', this.subtitle = '',
this.bullets = const [], this.bullets = const [],
this.bullets2 = const [], this.bullets2 = const [],
this.columnTitle1 = '',
this.columnTitle2 = '',
this.imagePath = '', this.imagePath = '',
this.imagePath2 = '', this.imagePath2 = '',
this.imageCaption = '', this.imageCaption = '',
@ -176,6 +183,8 @@ class Slide {
subtitle: src.subtitle, subtitle: src.subtitle,
bullets: List<String>.from(src.bullets), bullets: List<String>.from(src.bullets),
bullets2: List<String>.from(src.bullets2), bullets2: List<String>.from(src.bullets2),
columnTitle1: src.columnTitle1,
columnTitle2: src.columnTitle2,
imagePath: src.imagePath, imagePath: src.imagePath,
imagePath2: src.imagePath2, imagePath2: src.imagePath2,
imageCaption: src.imageCaption, imageCaption: src.imageCaption,
@ -206,6 +215,8 @@ class Slide {
String? subtitle, String? subtitle,
List<String>? bullets, List<String>? bullets,
List<String>? bullets2, List<String>? bullets2,
String? columnTitle1,
String? columnTitle2,
String? imagePath, String? imagePath,
String? imagePath2, String? imagePath2,
String? imageCaption, String? imageCaption,
@ -235,6 +246,8 @@ class Slide {
subtitle: subtitle ?? this.subtitle, subtitle: subtitle ?? this.subtitle,
bullets: bullets ?? this.bullets, bullets: bullets ?? this.bullets,
bullets2: bullets2 ?? this.bullets2, bullets2: bullets2 ?? this.bullets2,
columnTitle1: columnTitle1 ?? this.columnTitle1,
columnTitle2: columnTitle2 ?? this.columnTitle2,
imagePath: imagePath ?? this.imagePath, imagePath: imagePath ?? this.imagePath,
imagePath2: imagePath2 ?? this.imagePath2, imagePath2: imagePath2 ?? this.imagePath2,
imageCaption: imageCaption ?? this.imageCaption, imageCaption: imageCaption ?? this.imageCaption,

View file

@ -18,6 +18,9 @@ class MarkdownService {
buf.writeln('theme: ${deck.theme}'); buf.writeln('theme: ${deck.theme}');
if (deck.paginate) buf.writeln('paginate: true'); if (deck.paginate) buf.writeln('paginate: true');
// General presentation metadata (also picked up by Marp where applicable). // General presentation metadata (also picked up by Marp where applicable).
if (deck.title.isNotEmpty) {
buf.writeln('title: ${_yamlScalar(deck.title)}');
}
if (deck.author.isNotEmpty) { if (deck.author.isNotEmpty) {
buf.writeln('author: ${_yamlScalar(deck.author)}'); buf.writeln('author: ${_yamlScalar(deck.author)}');
} }
@ -212,6 +215,7 @@ class MarkdownService {
case SlideType.bullets: case SlideType.bullets:
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
if (slide.subtitle.isNotEmpty) buf.writeln('## ${slide.subtitle}');
buf.writeln(); buf.writeln();
for (final b in slide.bullets) { for (final b in slide.bullets) {
_writeBullet(buf, b); _writeBullet(buf, b);
@ -220,7 +224,13 @@ class MarkdownService {
case SlideType.twoBullets: case SlideType.twoBullets:
if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}');
buf.writeln(); buf.writeln();
_writeTwoBulletColumns(buf, slide.bullets, slide.bullets2); _writeTwoBulletColumns(
buf,
slide.bullets,
slide.bullets2,
slide.columnTitle1,
slide.columnTitle2,
);
case SlideType.bulletsImage: case SlideType.bulletsImage:
if (slide.imagePath.isNotEmpty) { if (slide.imagePath.isNotEmpty) {
@ -406,17 +416,42 @@ class MarkdownService {
StringBuffer buf, StringBuffer buf,
List<String> left, List<String> left,
List<String> right, List<String> right,
String leftTitle,
String rightTitle,
) { ) {
buf.writeln('<!-- ocideck_two_bullets_left: ${_encodeBullets(left)} -->'); buf.writeln('<!-- ocideck_two_bullets_left: ${_encodeBullets(left)} -->');
buf.writeln('<!-- ocideck_two_bullets_right: ${_encodeBullets(right)} -->'); buf.writeln('<!-- ocideck_two_bullets_right: ${_encodeBullets(right)} -->');
if (leftTitle.isNotEmpty) {
buf.writeln(
'<!-- ocideck_two_bullets_left_title: ${_encodeText(leftTitle)} -->',
);
}
if (rightTitle.isNotEmpty) {
buf.writeln(
'<!-- ocideck_two_bullets_right_title: ${_encodeText(rightTitle)} -->',
);
}
buf.writeln( buf.writeln(
'<div class="ocideck-two-bullets" style="display:grid; grid-template-columns:1fr 1fr; gap:3rem; align-items:start;">', '<div class="ocideck-two-bullets" style="display:grid; grid-template-columns:1fr 1fr; gap:3rem; align-items:start;">',
); );
_writeBulletColumn(buf, left, leftTitle);
_writeBulletColumn(buf, right, rightTitle);
buf.writeln('</div>');
}
static void _writeBulletColumn(
StringBuffer buf,
List<String> bullets,
String columnTitle,
) {
buf.writeln('<div>');
if (columnTitle.isNotEmpty) {
buf.writeln(
'<h3 style="margin:0 0 .5rem;">${_escapeHtml(columnTitle)}</h3>',
);
}
buf.writeln('<ul style="margin:0; padding-left:1.3em;">'); buf.writeln('<ul style="margin:0; padding-left:1.3em;">');
_writeHtmlBulletItems(buf, left); _writeHtmlBulletItems(buf, bullets);
buf.writeln('</ul>');
buf.writeln('<ul style="margin:0; padding-left:1.3em;">');
_writeHtmlBulletItems(buf, right);
buf.writeln('</ul>'); buf.writeln('</ul>');
buf.writeln('</div>'); buf.writeln('</div>');
} }
@ -425,6 +460,17 @@ class MarkdownService {
return base64Url.encode(utf8.encode(jsonEncode(bullets))); 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<String> _decodeBullets(String encoded) { static List<String> _decodeBullets(String encoded) {
try { try {
final decoded = utf8.decode(base64Url.decode(encoded.trim())); final decoded = utf8.decode(base64Url.decode(encoded.trim()));
@ -523,6 +569,7 @@ class MarkdownService {
String theme = 'ocideck'; String theme = 'ocideck';
bool paginate = true; bool paginate = true;
ThemeProfile themeProfile = const ThemeProfile(); ThemeProfile themeProfile = const ThemeProfile();
String? presentationTitle;
String author = ''; String author = '';
String organization = ''; String organization = '';
String version = ''; String version = '';
@ -541,6 +588,8 @@ class MarkdownService {
theme = line.substring(6).trim(); theme = line.substring(6).trim();
} else if (line.startsWith('paginate:')) { } else if (line.startsWith('paginate:')) {
paginate = line.substring(9).trim() == 'true'; paginate = line.substring(9).trim() == 'true';
} else if (line.startsWith('title:')) {
presentationTitle = _parseScalar(line.substring(6));
} else if (line.startsWith('author:')) { } else if (line.startsWith('author:')) {
author = _parseScalar(line.substring(7)); author = _parseScalar(line.substring(7));
} else if (line.startsWith('organization:')) { } else if (line.startsWith('organization:')) {
@ -574,10 +623,11 @@ class MarkdownService {
if (slide != null) slides.add(slide); if (slide != null) slides.add(slide);
} }
String title = 'Presentatie'; final title =
if (slides.isNotEmpty && slides.first.title.isNotEmpty) { presentationTitle ??
title = slides.first.title; (slides.isNotEmpty && slides.first.title.isNotEmpty
} ? slides.first.title
: 'Presentatie');
String? projectPath; String? projectPath;
if (filePath != null) { if (filePath != null) {
@ -626,6 +676,8 @@ class MarkdownService {
TlpLevel slideTlp = TlpLevel.none; TlpLevel slideTlp = TlpLevel.none;
final bullets = <String>[]; final bullets = <String>[];
var bullets2 = <String>[]; var bullets2 = <String>[];
var columnTitle1 = '';
var columnTitle2 = '';
// bulletsImage slides store their panel width in `<!-- _style: // bulletsImage slides store their panel width in `<!-- _style:
// --image-width: N%; -->`; capture it before the comment is stripped. // --image-width: N%; -->`; capture it before the comment is stripped.
int styleImageWidth = 0; int styleImageWidth = 0;
@ -646,6 +698,10 @@ class MarkdownService {
bullets bullets
..clear() ..clear()
..addAll(_decodeBullets(content.substring(25))); ..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:')) { } else if (content.startsWith('ocideck_two_bullets_right:')) {
bullets2 = _decodeBullets(content.substring(26)); bullets2 = _decodeBullets(content.substring(26));
} else if (!content.startsWith('_')) { } else if (!content.startsWith('_')) {
@ -844,6 +900,8 @@ class MarkdownService {
subtitle: type == SlideType.section ? paragraph : h2, subtitle: type == SlideType.section ? paragraph : h2,
bullets: bullets, bullets: bullets,
bullets2: bullets2, bullets2: bullets2,
columnTitle1: columnTitle1,
columnTitle2: columnTitle2,
imagePath: imagePath, imagePath: imagePath,
imagePath2: imagePath2, imagePath2: imagePath2,
imageCaption: imageCaption, imageCaption: imageCaption,

View file

@ -16,6 +16,7 @@ class BulletsEditor extends StatefulWidget {
class _BulletsEditorState extends State<BulletsEditor> { class _BulletsEditorState extends State<BulletsEditor> {
late final TextEditingController _title; late final TextEditingController _title;
late final TextEditingController _subtitle;
late List<TextEditingController> _bullets; late List<TextEditingController> _bullets;
late List<int> _levels; late List<int> _levels;
late List<FocusNode> _focusNodes; late List<FocusNode> _focusNodes;
@ -27,6 +28,8 @@ class _BulletsEditorState extends State<BulletsEditor> {
super.initState(); super.initState();
_title = TextEditingController(text: widget.slide.title); _title = TextEditingController(text: widget.slide.title);
_title.addListener(_emit); _title.addListener(_emit);
_subtitle = TextEditingController(text: widget.slide.subtitle);
_subtitle.addListener(_emit);
_initBullets(widget.slide.bullets); _initBullets(widget.slide.bullets);
} }
@ -55,6 +58,7 @@ class _BulletsEditorState extends State<BulletsEditor> {
widget.onUpdate( widget.onUpdate(
widget.slide.copyWith( widget.slide.copyWith(
title: _title.text, title: _title.text,
subtitle: _subtitle.text,
bullets: List.generate( bullets: List.generate(
_bullets.length, _bullets.length,
(i) => '\t' * _levels[i] + _bullets[i].text, (i) => '\t' * _levels[i] + _bullets[i].text,
@ -151,6 +155,7 @@ class _BulletsEditorState extends State<BulletsEditor> {
@override @override
void dispose() { void dispose() {
_title.dispose(); _title.dispose();
_subtitle.dispose();
for (final c in _bullets) { for (final c in _bullets) {
c.dispose(); c.dispose();
} }
@ -167,6 +172,12 @@ class _BulletsEditorState extends State<BulletsEditor> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'), 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 SizedBox(height: 16),
const SectionLabel('Bullets'), const SectionLabel('Bullets'),
ReorderableListView( ReorderableListView(

View file

@ -22,6 +22,8 @@ class TwoBulletsEditor extends StatefulWidget {
class _TwoBulletsEditorState extends State<TwoBulletsEditor> { class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
late final TextEditingController _title; late final TextEditingController _title;
late final TextEditingController _heading1;
late final TextEditingController _heading2;
late _BulletSet _left; late _BulletSet _left;
late _BulletSet _right; late _BulletSet _right;
@ -30,6 +32,10 @@ class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
super.initState(); super.initState();
_title = TextEditingController(text: widget.slide.title); _title = TextEditingController(text: widget.slide.title);
_title.addListener(_emit); _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); _left = _BulletSet(widget.slide.bullets, _emit);
_right = _BulletSet(widget.slide.bullets2, _emit); _right = _BulletSet(widget.slide.bullets2, _emit);
} }
@ -38,6 +44,8 @@ class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
widget.onUpdate( widget.onUpdate(
widget.slide.copyWith( widget.slide.copyWith(
title: _title.text, title: _title.text,
columnTitle1: _heading1.text,
columnTitle2: _heading2.text,
bullets: _left.values, bullets: _left.values,
bullets2: _right.values, bullets2: _right.values,
), ),
@ -47,6 +55,8 @@ class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
@override @override
void dispose() { void dispose() {
_title.dispose(); _title.dispose();
_heading1.dispose();
_heading2.dispose();
_left.dispose(); _left.dispose();
_right.dispose(); _right.dispose();
super.dispose(); super.dispose();
@ -63,8 +73,18 @@ class _TwoBulletsEditorState extends State<TwoBulletsEditor> {
builder: (context, constraints) { builder: (context, constraints) {
final narrow = constraints.maxWidth < 560; final narrow = constraints.maxWidth < 560;
final columns = [ final columns = [
_BulletColumn(label: 'Bullets links', set: _left, emit: _emit), _BulletColumn(
_BulletColumn(label: 'Bullets rechts', set: _right, emit: _emit), label: 'Bullets links',
set: _left,
emit: _emit,
headingController: _heading1,
),
_BulletColumn(
label: 'Bullets rechts',
set: _right,
emit: _emit,
headingController: _heading2,
),
]; ];
if (narrow) { if (narrow) {
return Column( return Column(
@ -202,11 +222,13 @@ class _BulletColumn extends StatefulWidget {
final String label; final String label;
final _BulletSet set; final _BulletSet set;
final VoidCallback emit; final VoidCallback emit;
final TextEditingController headingController;
const _BulletColumn({ const _BulletColumn({
required this.label, required this.label,
required this.set, required this.set,
required this.emit, required this.emit,
required this.headingController,
}); });
@override @override
@ -222,6 +244,14 @@ class _BulletColumnState extends State<_BulletColumn> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TextField(
controller: widget.headingController,
decoration: InputDecoration(
labelText: l10n.d('Kop (optioneel)'),
isDense: true,
),
),
const SizedBox(height: 12),
SectionLabel(widget.label), SectionLabel(widget.label),
const SizedBox(height: 6), const SizedBox(height: 6),
for (int i = 0; i < set.controllers.length; i++) _buildRow(i), for (int i = 0; i < set.controllers.length; i++) _buildRow(i),

View file

@ -646,6 +646,7 @@ class _BulletsPreview extends StatelessWidget {
final pad = w * 0.07; final pad = w * 0.07;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final titleSize = w * 0.042; final titleSize = w * 0.042;
final subtitleSize = w * 0.030;
final bulletSize = w * 0.026; final bulletSize = w * 0.026;
final spacing = pad * 0.5; final spacing = pad * 0.5;
final bulletGap = w * 0.006; final bulletGap = w * 0.006;
@ -653,6 +654,8 @@ class _BulletsPreview extends StatelessWidget {
.where((b) => b.trimLeft().isNotEmpty) .where((b) => b.trimLeft().isNotEmpty)
.toList(); .toList();
final hasTitle = slide.title.isNotEmpty; final hasTitle = slide.title.isNotEmpty;
final subtitle = slide.subtitle;
final hasSubtitle = subtitle.isNotEmpty;
final slideHeight = w * 9 / 16; final slideHeight = w * 9 / 16;
final availW = (w - pad * 2).clamp(w * 0.12, w); final availW = (w - pad * 2).clamp(w * 0.12, w);
@ -669,6 +672,9 @@ class _BulletsPreview extends StatelessWidget {
bulletSize: bulletSize, bulletSize: bulletSize,
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
maxScale: _kSplitBulletsMaxScale, maxScale: _kSplitBulletsMaxScale,
); );
@ -705,7 +711,23 @@ class _BulletsPreview extends StatelessWidget {
), ),
linkColor: _hexColor(profile.accentColor), 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), SizedBox(height: spacing * scale),
...bullets.map((b) { ...bullets.map((b) {
int level = 0; int level = 0;
@ -900,6 +922,62 @@ class _TwoBulletsPreview extends StatelessWidget {
required this.profile, 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<String> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pad = w * 0.065; final pad = w * 0.065;
@ -917,6 +995,12 @@ class _TwoBulletsPreview extends StatelessWidget {
.toList(); .toList();
final hasTitle = slide.title.isNotEmpty; 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 slideHeight = w * 9 / 16;
final contentW = (w - pad * 2).clamp(w * 0.12, w); final contentW = (w - pad * 2).clamp(w * 0.12, w);
final columnW = ((contentW - columnGap) / 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, titleSize,
contentW, contentW,
bold: true, bold: true,
fontFamily: font,
); );
availH -= spacing; 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( final leftScale = _bulletsFitScale(
availW: columnW, availW: columnW,
availH: availH, availH: availH,
@ -940,6 +1034,7 @@ class _TwoBulletsPreview extends StatelessWidget {
bulletSize: bulletSize, bulletSize: bulletSize,
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font,
maxScale: _kBulletsMaxScale, maxScale: _kBulletsMaxScale,
); );
final rightScale = _bulletsFitScale( final rightScale = _bulletsFitScale(
@ -952,6 +1047,7 @@ class _TwoBulletsPreview extends StatelessWidget {
bulletSize: bulletSize, bulletSize: bulletSize,
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font,
maxScale: _kBulletsMaxScale, maxScale: _kBulletsMaxScale,
); );
final scale = leftScale < rightScale ? leftScale : rightScale; final scale = leftScale < rightScale ? leftScale : rightScale;
@ -993,28 +1089,30 @@ class _TwoBulletsPreview extends StatelessWidget {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( _bulletColumn(
width: columnW, context,
child: _BulletListColumn( title: col1Title,
bullets: leftBullets, bullets: leftBullets,
font: font, columnW: columnW,
profile: profile, headingSize: headingSize,
bulletSize: bulletSize, headingSlotH: hasColumnTitles ? maxHeadingH : 0,
bulletGap: bulletGap, headingGap: headingGap,
scale: scale, bulletSize: bulletSize,
), bulletGap: bulletGap,
scale: scale,
), ),
SizedBox(width: columnGap), SizedBox(width: columnGap),
SizedBox( _bulletColumn(
width: columnW, context,
child: _BulletListColumn( title: col2Title,
bullets: rightBullets, bullets: rightBullets,
font: font, columnW: columnW,
profile: profile, headingSize: headingSize,
bulletSize: bulletSize, headingSlotH: hasColumnTitles ? maxHeadingH : 0,
bulletGap: bulletGap, headingGap: headingGap,
scale: scale, bulletSize: bulletSize,
), bulletGap: bulletGap,
scale: scale,
), ),
], ],
), ),
@ -1087,6 +1185,7 @@ class _BulletsImagePreview extends StatelessWidget {
bulletSize: bulletSize, bulletSize: bulletSize,
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font,
maxScale: _kBulletsMaxScale, maxScale: _kBulletsMaxScale,
); );
@ -1329,6 +1428,9 @@ double _bulletsFitScale({
required double bulletSize, required double bulletSize,
required double spacing, required double spacing,
required double bulletGap, required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
double minScale = 0.2, double minScale = 0.2,
double maxScale = 1.0, double maxScale = 1.0,
}) { }) {
@ -1345,6 +1447,9 @@ double _bulletsFitScale({
bulletSize: bulletSize, bulletSize: bulletSize,
spacing: spacing, spacing: spacing,
bulletGap: bulletGap, bulletGap: bulletGap,
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
); );
// Everything already fits at the largest allowed size use it. // Everything already fits at the largest allowed size use it.
@ -1381,12 +1486,33 @@ double _bulletsBlockHeight({
required double bulletSize, required double bulletSize,
required double spacing, required double spacing,
required double bulletGap, required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
}) { }) {
var height = 0.0; var height = 0.0;
if (hasTitle) { 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) { for (final b in bullets) {
int level = 0; int level = 0;
while (level < b.length && b[level] == '\t') { while (level < b.length && b[level] == '\t') {
@ -1396,15 +1522,21 @@ double _bulletsBlockHeight({
final fontSize = bulletSize * _bulletLevelScale(level) * scale; final fontSize = bulletSize * _bulletLevelScale(level) * scale;
final indent = level * bulletSize * 1.05 * scale; final indent = level * bulletSize * 1.05 * scale;
final marker = '${_bulletMarkerForLevel(level)} '; 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 wrapW = (availW - indent - markerW).clamp(1.0, availW);
final textH = _measureTextHeight( final textH = _measureTextHeight(
text, text,
fontSize, fontSize,
wrapW, wrapW,
lineHeight: _kBulletLineHeight, 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); height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH);
} }
return height; return height;
@ -1416,11 +1548,13 @@ double _measureTextHeight(
double maxWidth, { double maxWidth, {
double? lineHeight, double? lineHeight,
bool bold = false, bool bold = false,
String? fontFamily,
}) { }) {
final painter = TextPainter( final painter = TextPainter(
text: TextSpan( text: TextSpan(
text: stripInlineMarkdown(text), text: stripInlineMarkdown(text),
style: TextStyle( style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize, fontSize: fontSize,
height: lineHeight, height: lineHeight,
fontWeight: bold ? FontWeight.bold : null, fontWeight: bold ? FontWeight.bold : null,
@ -1431,11 +1565,17 @@ double _measureTextHeight(
return painter.height; 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( final painter = TextPainter(
text: TextSpan( text: TextSpan(
text: stripInlineMarkdown(text), text: stripInlineMarkdown(text),
style: TextStyle( style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize, fontSize: fontSize,
fontWeight: bold ? FontWeight.bold : null, fontWeight: bold ? FontWeight.bold : null,
), ),

View file

@ -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', () { test('twoBullets slide keeps both bullet columns', () {
final out = _roundTrip( final out = _roundTrip(
Slide.create(SlideType.twoBullets).copyWith( Slide.create(SlideType.twoBullets).copyWith(
@ -90,6 +104,39 @@ void main() {
expect(out.bullets2, ['Rechts punt', '\t\tRechts diep']); 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', () { test('bulletsImage slide keeps bullets, image, size and caption', () {
final out = _roundTrip( final out = _roundTrip(
Slide.create(SlideType.bulletsImage).copyWith( Slide.create(SlideType.bulletsImage).copyWith(
@ -386,7 +433,8 @@ void main() {
); );
final deck = service.parseDeck(markdown); final deck = service.parseDeck(markdown);
expect(deck, isNotNull); expect(deck, isNotNull);
expect(deck!.author, 'Jan Jansen'); expect(deck!.title, 'Demo');
expect(deck.author, 'Jan Jansen');
expect(deck.organization, 'Vigilis'); expect(deck.organization, 'Vigilis');
expect(deck.version, '1.2'); expect(deck.version, '1.2');
expect(deck.date, '2026-05-30'); expect(deck.date, '2026-05-30');

View file

@ -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);
});
}