From 42055047d29587dc0319c18c42cb1a2a3cf22d29 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:04:07 +0000 Subject: [PATCH 1/7] Let a bridged row hold a gap and a subtitle under each side Two things a Bridged section could not do. A bridge could be a string or a piece of artwork but never nothing, so two columns of text with plain space between them had to be faked with a bridge drawn in the background colour; and a row was one line a side, so a role under a name meant a second section that could not be kept in step with the first. `BridgeType::None` draws nothing. It is a type in the table rather than an absence so every other setting on the row goes on meaning what it meant, and it brings one number with it -- `bridgeMinGap` -- because an empty bridge has no natural width to keep the two texts off each other with under Natural sizing. Its fill is forced to Fixed through `effectiveBridgeFill`, since three ways to fill a gap with nothing differ only in how the columns come out, and the editor hides the row to match. `rowSubtitles` stacks a second line under each side, from two new fields on the entry and with a style each: the two sides of a bridged row are already styled apart, and a subtitle that could not follow the line above it would be the one part of the row unable to. Both sides now go through the pair helper whatever they carry, so a row without subtitles is measured and drawn by the same code that has always drawn it -- an empty line takes no height and no gap. The leader hangs off whichever line ends up on top, the rule a bridged logo row already follows, so adding a subtitle leaves it where it was. The entry table now carries the whole entry on each row's first cell, because reading a section back rebuilds its entries from the columns on screen and a field without a column would otherwise be dropped by the next read -- a subtitle whose switch is off, or the text behind a type that shows logos. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SctZkVkj2RxyH7oJUvH6wQ --- data/locale/en-US.ini | 9 ++ src/model/BridgeArt.cpp | 14 +- src/model/BridgeArt.hpp | 18 ++- src/model/CreditsModel.cpp | 142 ++++++++++++++++++- src/model/CreditsModel.hpp | 93 ++++++++++++- src/render/StripRenderer.cpp | 145 ++++++++++++++----- src/ui/CsvImportDialog.cpp | 20 ++- src/ui/CsvImportDialog.hpp | 10 +- src/ui/SectionEditor.cpp | 217 +++++++++++++++++++++++++++-- src/ui/SectionEditor.hpp | 16 ++- tests/CMakeLists.txt | 1 + tests/harness/Fixtures.cpp | 10 +- tests/suites/BridgedRows.cpp | 223 ++++++++++++++++++++++++++++++ tests/suites/Fonts.cpp | 2 +- tests/suites/Layout.cpp | 2 +- tests/suites/Persistence.cpp | 21 ++- tests/suites/StyleLibrary.cpp | 2 +- tests/suites/SubtitleHeadings.cpp | 2 +- 18 files changed, 879 insertions(+), 68 deletions(-) create mode 100644 tests/suites/BridgedRows.cpp diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 08f7c4a..955855d 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -97,6 +97,7 @@ Designer.LogoAnimatedShadow="Shadow follows the animation" Designer.LogoAnimatedShadow.Tip="Recompute the drop shadow for every frame instead of casting the first frame's. Only worth it for artwork that changes shape as it plays; it costs a blur per frame when the roll is built." Designer.LogoTruncated="Animated artwork longer than %1 seconds plays only its first %1 seconds." Designer.BridgeType="Bridge Type" +Designer.BridgeType.none="Nothing — just a gap" Designer.BridgeType.text="Text you type" Designer.BridgeType.dots="Dots" Designer.BridgeType.dashes="Dashes" @@ -114,6 +115,8 @@ Designer.BridgeOffset="Bridge Height" Designer.BridgeOffset.Tip="How far above the text's baseline the artwork sits. At 0 it rests on the baseline, like a run of leader dots." Designer.BridgeGap="Bridge End Gap" Designer.BridgeGap.Tip="Space left clear at each end of the artwork, so it does not run into the words it joins." +Designer.BridgeMinGap="Gap Width" +Designer.BridgeMinGap.Tip="The least space kept between the two texts when the bridge draws nothing. Anything the columns leave over past it belongs to the gap as well." Designer.BridgeTint="Draw the artwork in the text's own colour" Designer.BridgeFill="Bridge Fill" Designer.BridgeFill.Fixed="Draw it once, at its own width" @@ -126,6 +129,8 @@ Designer.BridgeSplit="Bridge Starts At" Designer.BridgeSplit.Tip="How much of the width goes to the left-hand text before the bridge begins." Designer.BridgeRowAlign="Row Placement" Designer.BridgeSpanEmpty="Run the bridge to the edge when one side is empty" +Designer.RowSubtitles="Give each side a subtitle" +Designer.RowSubtitles.Tip="Adds a second line under each side of every row, from the Left Subtitle and Right Subtitle columns. A row that leaves one empty draws nothing there and takes up no more room for it." Designer.Columns="Columns" Designer.ColumnGap="Column Gap" Designer.FillOrder="Fill Order" @@ -200,6 +205,8 @@ Designer.StylePreset.NamePrompt="Name this style preset. Sections that follow it Designer.StylePreset.DeleteConfirm="Delete the style preset '%1'? Sections following it fall back to their own style." Designer.SecondaryStyle="Separate Style For The Right Column" Designer.SubtitleStyle="Separate Style For The Subtitle" +Designer.RowSubtitleStyle="Style For The Left Subtitle" +Designer.RowSecondarySubtitleStyle="Style For The Right Subtitle" Designer.BridgeStyle="Separate Colour For The Bridge" Designer.DividerArtStyle="Separate Colour For The Artwork" Designer.BridgeStyle.Tip="Colours the bridge on its own instead of drawing it in the section's text style — yellow dots under white names, or a gradient across a run of diamonds. Only the fill, outline and shadow come from here: the bridge keeps the row's own font and size, so nothing in the row moves when this is switched on." @@ -242,6 +249,8 @@ Designer.SetLogo="Set Logo..." Designer.ImportCsv="Import CSV..." Designer.Column.Text="Text" Designer.Column.Left="Left" +Designer.Column.LeftSubtitle="Left Subtitle" +Designer.Column.RightSubtitle="Right Subtitle" Designer.Column.Right="Right" Designer.Column.EntryTitle="Title" Designer.Column.Subtitle="Subtitle" diff --git a/src/model/BridgeArt.cpp b/src/model/BridgeArt.cpp index 9576fea..e74524a 100644 --- a/src/model/BridgeArt.cpp +++ b/src/model/BridgeArt.cpp @@ -38,6 +38,13 @@ namespace { * either side of it. */ const BridgeTypeInfo kBridgeTypes[] = { + /* + * Nothing drawn: the row is two texts with a gap between them. Listed first because it is + * the plainest thing the picker can offer, not because it is the fallback -- that is still + * Text, so a document written before this existed keeps the leader it was built against. + */ + {BridgeType::None, "none", "None", "", 0.0, BridgeStretch::Spread}, + {BridgeType::Text, "text", "Text", "", 0.0, BridgeStretch::Spread}, {BridgeType::Dots, "dots", "Dots", R"()", 2.0, @@ -117,7 +124,12 @@ const QVector &allBridgeTypes() bool bridgeTypeUsesArt(BridgeType type) { - return type != BridgeType::Text; + return type != BridgeType::Text && type != BridgeType::None; +} + +bool bridgeTypeIsEmpty(BridgeType type) +{ + return type == BridgeType::None; } bool bridgeTypeUsesFile(BridgeType type) diff --git a/src/model/BridgeArt.hpp b/src/model/BridgeArt.hpp index d3d8e79..0841d3f 100644 --- a/src/model/BridgeArt.hpp +++ b/src/model/BridgeArt.hpp @@ -35,9 +35,16 @@ namespace closingtime { * `Custom` takes its tile from a file the user picks. Every other art type takes it from the * table in BridgeArt.cpp, and adding one there is a row plus the markup for its tile. * + * `None` draws nothing at all. It is a real type rather than an absence because a row's two + * texts held apart by a plain gap is a layout people ask for -- a cast list set as two columns + * with nothing running between them -- and expressing it as "the bridge is empty" keeps every + * other setting on the row meaning exactly what it always did. What it costs is one number, + * `Section::bridgeMinGap`, since an empty bridge has no natural width of its own to keep the + * two texts off each other with. + * * Persisted by string id (bridgeTypeId), never by ordinal, so the enum may be reordered. */ -enum class BridgeType { Text, Dots, Dashes, Line, DoubleLine, Diamonds, Custom }; +enum class BridgeType { None, Text, Dots, Dashes, Line, DoubleLine, Diamonds, Custom }; /* * What a type does when asked to cover a gap wider than one tile. @@ -85,6 +92,15 @@ const QVector &allBridgeTypes(); /* True when the type is drawn from vector art rather than from the section's bridge string. */ bool bridgeTypeUsesArt(BridgeType type); +/* + * True when the type draws nothing, leaving the space between the two texts empty. + * + * Worth asking by name rather than comparing against None at each call site: an empty bridge is + * neither text nor art, so both of the questions above answer "no" for it and neither of them + * says what it actually is. + */ +bool bridgeTypeIsEmpty(BridgeType type); + /* True when the art comes from a file the user picks rather than from the built-in table. */ bool bridgeTypeUsesFile(BridgeType type); diff --git a/src/model/CreditsModel.cpp b/src/model/CreditsModel.cpp index c68a480..acbe7f2 100644 --- a/src/model/CreditsModel.cpp +++ b/src/model/CreditsModel.cpp @@ -181,6 +181,18 @@ bool sectionUsesSubtitles(SectionType type) return sectionTypeInfo(type).subtitles; } +bool sectionStacksSubtitles(const Section §ion) +{ + /* + * A bridged row is the one shape whose subtitles are a choice rather than a property of the + * type, so it is the one that has to be asked about the section rather than about the type. + */ + if (section.type == SectionType::Bridged) + return section.rowSubtitles; + + return sectionUsesSubtitles(section.type); +} + bool sectionUsesSecondaryText(SectionType type) { return type == SectionType::Bridged || sectionUsesSubtitles(type); @@ -338,6 +350,17 @@ BridgeSizing bridgeSizingFromId(const char *id, BridgeSizing fallback) return fallback; } +BridgeFill effectiveBridgeFill(const Section §ion) +{ + /* + * An empty bridge has nothing to cover a gap with, so the three fills would differ only in + * how they measure the text columns -- invisibly, from a control whose name promises + * something visible. Fixed is the one that behaves the way an empty gap reads: the minimum + * is reserved between the two columns and the split divides what is left of them. + */ + return bridgeTypeIsEmpty(section.bridgeType) ? BridgeFill::Fixed : section.bridgeFill; +} + const char *textFillId(TextFill fill) { switch (fill) { @@ -622,6 +645,8 @@ void Entry::save(obs_data_t *data) const { obs_data_set_string(data, "text", text.toUtf8().constData()); obs_data_set_string(data, "secondary_text", secondaryText.toUtf8().constData()); + obs_data_set_string(data, "subtitle", subtitle.toUtf8().constData()); + obs_data_set_string(data, "secondary_subtitle", secondarySubtitle.toUtf8().constData()); OBSDataAutoRelease logoData = obs_data_create(); logo.save(logoData); @@ -632,6 +657,10 @@ void Entry::load(obs_data_t *data) { text = QString::fromUtf8(obs_data_get_string(data, "text")); secondaryText = QString::fromUtf8(obs_data_get_string(data, "secondary_text")); + /* Absent in every entry written before bridged rows could carry them, and an absent + * subtitle is an empty one: nothing is drawn and nothing has to be migrated. */ + subtitle = QString::fromUtf8(obs_data_get_string(data, "subtitle")); + secondarySubtitle = QString::fromUtf8(obs_data_get_string(data, "secondary_subtitle")); OBSDataAutoRelease logoData = obs_data_get_obj(data, "logo"); if (logoData) @@ -682,12 +711,14 @@ void Section::save(obs_data_t *data) const obs_data_set_double(data, "bridge_thickness", bridgeThickness); obs_data_set_double(data, "bridge_offset", bridgeOffset); obs_data_set_double(data, "bridge_gap", bridgeGap); + obs_data_set_double(data, "bridge_min_gap", bridgeMinGap); obs_data_set_bool(data, "bridge_tint", bridgeTint); obs_data_set_string(data, "bridge_fill", bridgeFillId(bridgeFill)); obs_data_set_string(data, "bridge_sizing", bridgeSizingId(bridgeSizing)); obs_data_set_double(data, "bridge_split", bridgeSplit); obs_data_set_string(data, "bridge_row_align", hAlignId(bridgeRowAlign)); obs_data_set_bool(data, "bridge_span_empty", bridgeSpanEmpty); + obs_data_set_bool(data, "row_subtitles", rowSubtitles); obs_data_set_string(data, "divider_cap", dividerShapeId(dividerCap)); obs_data_set_string(data, "divider_cap_svg", dividerCapSvg.toUtf8().constData()); obs_data_set_string(data, "divider_end_cap", dividerShapeId(dividerEndCap)); @@ -720,6 +751,9 @@ void Section::save(obs_data_t *data) const obs_data_set_string(data, "style_preset", stylePresetName.toUtf8().constData()); obs_data_set_string(data, "secondary_style_preset", secondaryStylePresetName.toUtf8().constData()); obs_data_set_string(data, "bridge_style_preset", bridgeStylePresetName.toUtf8().constData()); + obs_data_set_string(data, "row_subtitle_style_preset", rowSubtitleStylePresetName.toUtf8().constData()); + obs_data_set_string(data, "row_secondary_subtitle_style_preset", + rowSecondarySubtitleStylePresetName.toUtf8().constData()); OBSDataAutoRelease logoData = obs_data_create(); logo.save(logoData); @@ -737,6 +771,14 @@ void Section::save(obs_data_t *data) const bridgeStyle.save(bridgeStyleData); obs_data_set_obj(data, "bridge_style", bridgeStyleData); + OBSDataAutoRelease rowSubtitleStyleData = obs_data_create(); + rowSubtitleStyle.save(rowSubtitleStyleData); + obs_data_set_obj(data, "row_subtitle_style", rowSubtitleStyleData); + + OBSDataAutoRelease rowSecondarySubtitleStyleData = obs_data_create(); + rowSecondarySubtitleStyle.save(rowSecondarySubtitleStyleData); + obs_data_set_obj(data, "row_secondary_subtitle_style", rowSecondarySubtitleStyleData); + saveArray( data, "entries", entries.size(), [](obs_data_t *item, int index, const void *context) { @@ -776,6 +818,14 @@ void Section::load(obs_data_t *data) */ bridgeGap = obs_data_has_user_value(data, "bridge_gap") ? obs_data_get_double(data, "bridge_gap") : 8.0; bridgeGap = std::max(0.0, bridgeGap); + /* + * Nor is a zero minimum gap absurd -- two columns set hard against each other is a layout, + * if an unusual one -- so this is read the same careful way, and a document written before + * the empty bridge existed gets the default rather than a gap of nothing. + */ + bridgeMinGap = obs_data_has_user_value(data, "bridge_min_gap") ? obs_data_get_double(data, "bridge_min_gap") + : 24.0; + bridgeMinGap = std::max(0.0, bridgeMinGap); /* A thickness of zero would draw nothing at all, which no document ever means. */ bridgeThickness = obs_data_get_double(data, "bridge_thickness"); if (bridgeThickness <= 0.0) @@ -786,6 +836,9 @@ void Section::load(obs_data_t *data) bridgeSizing = bridgeSizingFromId(obs_data_get_string(data, "bridge_sizing"), BridgeSizing::Split); bridgeRowAlign = hAlignFromId(obs_data_get_string(data, "bridge_row_align"), HAlign::Center); bridgeSpanEmpty = obs_data_get_bool(data, "bridge_span_empty"); + /* Off in every document written before a bridged row could carry subtitles, which is also + * what an entry with nothing in either subtitle draws. */ + rowSubtitles = obs_data_get_bool(data, "row_subtitles"); /* * 0.0 is a legitimate split -- everything to the right of the bridge -- so a missing @@ -864,6 +917,9 @@ void Section::load(obs_data_t *data) stylePresetName = QString::fromUtf8(obs_data_get_string(data, "style_preset")); secondaryStylePresetName = QString::fromUtf8(obs_data_get_string(data, "secondary_style_preset")); bridgeStylePresetName = QString::fromUtf8(obs_data_get_string(data, "bridge_style_preset")); + rowSubtitleStylePresetName = QString::fromUtf8(obs_data_get_string(data, "row_subtitle_style_preset")); + rowSecondarySubtitleStylePresetName = + QString::fromUtf8(obs_data_get_string(data, "row_secondary_subtitle_style_preset")); if (columns < 1) columns = 1; @@ -907,6 +963,32 @@ void Section::load(obs_data_t *data) else bridgeStyle = style; + /* + * A subtitle style absent from the document is seeded the way a new section's is: the line + * it sits under, a size down. Falling back to the line's own style outright would draw a + * subtitle indistinguishable from the text above it, which is the one thing a subtitle must + * not be -- and a document that predates these has no opinion to preserve, since it carried + * no subtitles for them to be wrong about. + */ + const auto seedSubtitleStyle = [](const TextStyle &from) { + TextStyle seeded = from; + seeded.pixelSize = std::max(1, static_cast(std::lround(from.pixelSize * 0.7))); + seeded.bold = false; + return seeded; + }; + + OBSDataAutoRelease rowSubtitleStyleData = obs_data_get_obj(data, "row_subtitle_style"); + if (rowSubtitleStyleData) + rowSubtitleStyle.load(rowSubtitleStyleData); + else + rowSubtitleStyle = seedSubtitleStyle(style); + + OBSDataAutoRelease rowSecondarySubtitleStyleData = obs_data_get_obj(data, "row_secondary_subtitle_style"); + if (rowSecondarySubtitleStyleData) + rowSecondarySubtitleStyle.load(rowSecondarySubtitleStyleData); + else + rowSecondarySubtitleStyle = seedSubtitleStyle(useSecondaryStyle ? secondaryStyle : style); + entries.clear(); OBSDataArrayAutoRelease array = obs_data_get_array(data, "entries"); if (array) { @@ -1009,12 +1091,12 @@ Section Section::makeDefault(SectionType type) section.secondaryStyle.align = HAlign::Left; section.useSecondaryStyle = true; section.marginX = 160; - section.entries.append(Entry{QStringLiteral("Role"), QStringLiteral("Name"), {}}); + section.entries.append(Entry{QStringLiteral("Role"), QStringLiteral("Name"), {}, {}, {}}); break; case SectionType::TextList: section.style.pixelSize = 32; - section.entries.append(Entry{QStringLiteral("Name"), {}, {}}); + section.entries.append(Entry{QStringLiteral("Name"), {}, {}, {}, {}}); break; case SectionType::TitleSubtitleList: @@ -1044,7 +1126,7 @@ Section Section::makeDefault(SectionType type) section.marginX = 120; } for (int i = 0; i < count; ++i) - section.entries.append(Entry{QStringLiteral("Position"), QStringLiteral("Full Name"), {}}); + section.entries.append(Entry{QStringLiteral("Position"), QStringLiteral("Full Name"), {}, {}, {}}); break; } @@ -1057,7 +1139,7 @@ Section Section::makeDefault(SectionType type) section.columns = 3; section.marginX = 120; for (int i = 0; i < 3; ++i) - section.entries.append(Entry{QStringLiteral("Name"), {}, {}}); + section.entries.append(Entry{QStringLiteral("Name"), {}, {}, {}, {}}); break; case SectionType::MultiLogoList: @@ -1117,6 +1199,26 @@ Section Section::makeDefault(SectionType type) section.bridgeFill = BridgeFill::Repeat; } + /* + * The two subtitles a bridged row can carry are seeded from the line each of them sits + * under, a size down and without its weight, whatever the type -- switching a section to + * Bridged and turning the subtitles on then draws something that reads as a subtitle + * straight away, rather than a second line indistinguishable from the first. + * + * Done here for every type rather than in the Bridged arm because changing a section's type + * is non-destructive: the fields a type does not read are kept, so they had better be worth + * keeping by the time another type does read them. + */ + const auto subtitleOf = [](const TextStyle &line) { + TextStyle subtitle = line; + subtitle.pixelSize = std::max(1, static_cast(std::lround(line.pixelSize * 0.7))); + subtitle.bold = false; + return subtitle; + }; + section.rowSubtitleStyle = subtitleOf(section.style); + section.rowSecondarySubtitleStyle = + subtitleOf(section.useSecondaryStyle ? section.secondaryStyle : section.style); + return section; } @@ -1197,6 +1299,18 @@ const TextStyle &Document::effectiveSecondaryStyle(const Section §ion) const return preset ? *preset : section.secondaryStyle; } +const TextStyle &Document::effectiveRowSubtitleStyle(const Section §ion) const +{ + const TextStyle *preset = findStylePreset(section.rowSubtitleStylePresetName); + return preset ? *preset : section.rowSubtitleStyle; +} + +const TextStyle &Document::effectiveRowSecondarySubtitleStyle(const Section §ion) const +{ + const TextStyle *preset = findStylePreset(section.rowSecondarySubtitleStylePresetName); + return preset ? *preset : section.rowSecondarySubtitleStyle; +} + TextStyle Document::effectiveBridgeStyle(const Section §ion) const { TextStyle style = effectiveStyle(section); @@ -1289,6 +1403,10 @@ bool Document::applyLibraryRenames() section.secondaryStylePresetName = renamed; if (section.bridgeStylePresetName == from) section.bridgeStylePresetName = renamed; + if (section.rowSubtitleStylePresetName == from) + section.rowSubtitleStylePresetName = renamed; + if (section.rowSecondarySubtitleStylePresetName == from) + section.rowSecondarySubtitleStylePresetName = renamed; } changed = true; @@ -1348,6 +1466,10 @@ void Document::removeStylePreset(const QString &name) section.secondaryStylePresetName.clear(); if (section.bridgeStylePresetName == name) section.bridgeStylePresetName.clear(); + if (section.rowSubtitleStylePresetName == name) + section.rowSubtitleStylePresetName.clear(); + if (section.rowSecondarySubtitleStylePresetName == name) + section.rowSecondarySubtitleStylePresetName.clear(); } } @@ -1401,6 +1523,16 @@ QVector Document::usedFonts() const consider(effectiveStyle(section)); consider(effectiveSecondaryStyle(section)); + + /* + * The subtitles of a bridged row are asked for the same reason a divider's text is: + * they are drawn only when the section turns them on, and a family reported for a + * roll that never draws it is a font hunt with nothing at the end of it. + */ + if (sectionStacksSubtitles(section) && section.type == SectionType::Bridged) { + consider(effectiveRowSubtitleStyle(section)); + consider(effectiveRowSecondarySubtitleStyle(section)); + } } std::sort(fonts.begin(), fonts.end(), [](const FontUse &left, const FontUse &right) { @@ -1468,6 +1600,8 @@ bool Document::applyFontSubstitutions(const QStringList &families) for (Section §ion : sections) { rewrite(section.style); rewrite(section.secondaryStyle); + rewrite(section.rowSubtitleStyle); + rewrite(section.rowSecondarySubtitleStyle); /* * `bridgeStyle` is deliberately left alone: a bridge keeps the row's own font and * takes only ink from it, so the family recorded there is never drawn with. diff --git a/src/model/CreditsModel.hpp b/src/model/CreditsModel.hpp index a36d0c1..286a0c9 100644 --- a/src/model/CreditsModel.hpp +++ b/src/model/CreditsModel.hpp @@ -153,6 +153,33 @@ enum class BridgeSizing { Split, Natural }; const char *bridgeSizingId(BridgeSizing sizing); BridgeSizing bridgeSizingFromId(const char *id, BridgeSizing fallback = BridgeSizing::Split); +struct Section; + +/* + * The fill a section's bridge is really laid out with. + * + * An empty bridge has nothing to cover a gap with, so all three modes would come out looking the + * same on screen while quietly measuring the text columns three different ways -- a "fill" setting + * for something that draws nothing is exactly the kind of control that makes this editor hard to + * read. So an empty bridge is always laid out as Fixed, reserving `bridgeMinGap` between the two + * columns, and the designer hides the row rather than offering a choice that only moves text. + * + * Everything that measures or places a bridged row goes through this rather than reading + * `bridgeFill` directly, the same guarantee `effectiveStyle` gives the text. + */ +BridgeFill effectiveBridgeFill(const Section §ion); + +/* + * True when this section really stacks a subtitle under something, which is not a question the + * type alone can answer any more: a bridged row does it only when `rowSubtitles` is on. + * + * `sectionUsesSubtitles` stays a property of the type, since that is what the layout switch and + * the field visibility are written against; this is what anything asking about a particular + * section -- the editor's `subtitleGap` and `subtitleFirst` rows, which shape any stack -- should + * ask instead. + */ +bool sectionStacksSubtitles(const Section §ion); + /* * What the glyphs themselves are painted with. * @@ -398,7 +425,9 @@ struct LogoRef { /* * One entry inside a list-shaped section. Which fields matter depends on the owning * section's type: - * Bridged -> text (left) and secondaryText (right), joined by the bridge string + * Bridged -> text (left) and secondaryText (right), joined by the bridge string, + * each with an optional subtitle stacked under it (subtitle and + * secondarySubtitle) when the section asks for them * TextList -> text * MultiTextList -> text, placed into columns in the section's fill order * TitleSubtitleList -> text (title) and secondaryText (subtitle), stacked one over the other @@ -409,6 +438,18 @@ struct LogoRef { struct Entry { QString text; QString secondaryText; + /* + * Bridged rows only: a second line under each side of the row, drawn when the section's + * `rowSubtitles` is on and there is something here to draw. A role under a name on the left, + * a company under a name on the right. + * + * They are fields of the entry rather than a reuse of `text`/`secondaryText` because a + * bridged row already spends both of those on the two sides of the row -- and keeping them + * whatever the section's type means a list switched to another shape and back keeps them, + * exactly as every other field on the model does. + */ + QString subtitle; + QString secondarySubtitle; LogoRef logo; void save(obs_data_t *data) const; @@ -539,6 +580,16 @@ struct Section { * through the middle of the text. */ double bridgeOffset = 0.0; + /* + * Empty bridges only: the space the bridge takes up when there is nothing in it, in pixels. + * + * Every other type has a natural width of its own -- a string's advance, a tile's aspect -- + * and that width is what a Fixed bridge reserves between the two columns. An empty bridge + * has none, so without this a Natural-sized row would set its two texts hard against each + * other. It is a minimum rather than an exact gap for the same reason every other bridge's + * natural width is: whatever the columns leave over past it still belongs to the bridge. + */ + double bridgeMinGap = 24.0; /* * Art bridges only: space left at each end of the art, in pixels, so a leader does not * run into the words it joins. A text bridge carries its own spacing in the string the @@ -574,6 +625,21 @@ struct Section { */ bool bridgeSpanEmpty = false; + /* + * Bridged sections only: draw a second line under each side of every row, from the entry's + * `subtitle` and `secondarySubtitle`. + * + * A switch rather than a section type of its own, because a bridged row with subtitles is the + * same content laid out the same way with one more line under each side -- and because the + * subtitles are optional per row: a side with nothing in it draws nothing and takes no height, + * so a list where only some of the rows carry a role under the name needs no second section. + * + * Switching it off leaves the text where it is, like every other non-destructive choice here. + * The stack itself is shaped by `subtitleGap` and `subtitleFirst`, which mean here exactly + * what they mean for a title/subtitle list: the pair's own spacing, and which line is on top. + */ + bool rowSubtitles = false; + /* * Section Divider sections only. * @@ -682,6 +748,21 @@ struct Section { TextStyle bridgeStyle; bool useBridgeStyle = false; + /* + * The two subtitles of a bridged row, when `rowSubtitles` is on. + * + * One style each rather than one shared between them, because the two sides of a bridged row + * are already styled apart -- that is what `secondaryStyle` is for -- and a subtitle that + * could not follow the line it belongs under would be the one part of the row unable to. + * + * Unlike `secondaryStyle` these carry no switch of their own. There is nothing sensible for + * an off position to mean: a subtitle set in exactly the style of the line above it is not a + * subtitle, it is a second line of the same text, so `makeDefault` hands out a smaller size + * and the styles are simply used whenever the subtitles are drawn at all. + */ + TextStyle rowSubtitleStyle; + TextStyle rowSecondarySubtitleStyle; + /* * Names of the document style presets this section follows, or empty to use the * section's own `style`/`secondaryStyle`/`bridgeStyle`. A name that no longer resolves falls @@ -695,6 +776,8 @@ struct Section { QString stylePresetName; QString secondaryStylePresetName; QString bridgeStylePresetName; + QString rowSubtitleStylePresetName; + QString rowSecondarySubtitleStylePresetName; /* Vertical padding above and below the section's content, in pixels. */ int paddingTop = 16; @@ -790,6 +873,14 @@ struct Document { const TextStyle &effectiveStyle(const Section §ion) const; const TextStyle &effectiveSecondaryStyle(const Section §ion) const; + /* + * The styles the two subtitles of a bridged row are drawn with, once their preset bindings + * are resolved. Like every other effective-style accessor, a name that no longer resolves + * falls back to the section's own copy rather than failing. + */ + const TextStyle &effectiveRowSubtitleStyle(const Section §ion) const; + const TextStyle &effectiveRowSecondarySubtitleStyle(const Section §ion) const; + /* * The style a section's bridge is drawn with, once its own preset binding is resolved. * diff --git a/src/render/StripRenderer.cpp b/src/render/StripRenderer.cpp index 3cb469c..c8d27f3 100644 --- a/src/render/StripRenderer.cpp +++ b/src/render/StripRenderer.cpp @@ -424,6 +424,16 @@ qreal naturalTextWidth(const QString &text, const TextStyle &style) return std::ceil(widest); } +/* + * The width a title/subtitle pair wants before any wrapping is imposed on it: whichever of its + * two lines is wider, since the two are laid out into the same column. + */ +qreal naturalPairWidth(const QString &title, const QString &subtitle, const TextStyle &titleStyle, + const TextStyle &subtitleStyle) +{ + return std::max(naturalTextWidth(title, titleStyle), naturalTextWidth(subtitle, subtitleStyle)); +} + /* * Distance from the top of a run of this text down to the baseline of its first line, when laid * out into `width` pixels. @@ -774,6 +784,30 @@ LogoRow placeLogoRow(const Section §ion, qreal contentX, qreal contentWidth, return row; } +/* + * One side of a bridged row: the line, whatever is stacked under it, and the style of each. + * + * A side is a pair rather than a string because `rowSubtitles` lets either of them carry a second + * line, and every question the row asks of a side -- how wide does it want to be, where is its top + * line's baseline, is there anything in it at all -- has to be asked of both lines or of neither. + * Threading four more parameters through the placement instead would leave each of those questions + * free to answer for only half of the side, which is exactly the bug this shape cannot have. + * + * Pointers rather than copies because everything here is already owned by the entry or the section + * being laid out, and a style is the larger half of what would otherwise be copied once per row. + */ +struct BridgedSide { + const QString *text; + const QString *subtitle; + const TextStyle *style; + const TextStyle *subtitleStyle; + + bool isEmpty() const { return text->isEmpty() && subtitle->isEmpty(); } + + /* The width the side wants: whichever of its two lines is wider, since they share a column. */ + qreal naturalWidth() const { return naturalPairWidth(*text, *subtitle, *style, *subtitleStyle); } +}; + /* Where the three parts of one bridged row sit horizontally. */ struct BridgedRow { qreal leftX = 0.0; @@ -793,12 +827,15 @@ struct BridgedRow { * for a Fixed bridge with Natural sizing, since every other combination has already * consumed the full width. */ -BridgedRow placeBridgedRow(const Section §ion, const TextStyle &leftStyle, const TextStyle &rightStyle, - const Entry &entry, qreal contentX, qreal contentWidth, qreal naturalBridge) +BridgedRow placeBridgedRow(const Section §ion, const BridgedSide &left, const BridgedSide &right, qreal contentX, + qreal contentWidth, qreal naturalBridge) { qreal leftWidth = 0.0; qreal rightWidth = 0.0; + /* An empty bridge is laid out as a Fixed one whatever the setting says; see the model. */ + const BridgeFill fill = effectiveBridgeFill(section); + if (section.bridgeSizing == BridgeSizing::Split) { /* * The ratio divides the space the two texts share rather than the whole width, @@ -806,14 +843,13 @@ BridgedRow placeBridgedRow(const Section §ion, const TextStyle &leftStyle, c * have always drawn it. With a filling bridge there is nothing to reserve, and * the ratio becomes a plain tab stop for the leader to start at. */ - const qreal reserved = section.bridgeFill == BridgeFill::Fixed ? naturalBridge : 0.0; + const qreal reserved = fill == BridgeFill::Fixed ? naturalBridge : 0.0; leftWidth = section.bridgeSplit * std::max(0.0, contentWidth - reserved); - if (section.bridgeFill == BridgeFill::Fixed) { + if (fill == BridgeFill::Fixed) { rightWidth = std::max(0.0, contentWidth - leftWidth - naturalBridge); } else { - rightWidth = std::min(naturalTextWidth(entry.secondaryText, rightStyle), - std::max(0.0, contentWidth - leftWidth)); + rightWidth = std::min(right.naturalWidth(), std::max(0.0, contentWidth - leftWidth)); /* * With a filling bridge the split is a tab stop rather than a cap: there is @@ -822,12 +858,12 @@ BridgedRow placeBridgedRow(const Section §ion, const TextStyle &leftStyle, c * its column with the gap beside it left empty. Rows that do fit still start * their bridge at the same x, which is the whole point of the setting. */ - leftWidth = std::clamp(naturalTextWidth(entry.text, leftStyle), leftWidth, + leftWidth = std::clamp(left.naturalWidth(), leftWidth, std::max(leftWidth, contentWidth - rightWidth)); } } else { - leftWidth = naturalTextWidth(entry.text, leftStyle); - rightWidth = naturalTextWidth(entry.secondaryText, rightStyle); + leftWidth = left.naturalWidth(); + rightWidth = right.naturalWidth(); /* * Overlong rows shrink both sides in proportion rather than letting whichever @@ -847,10 +883,10 @@ BridgedRow placeBridgedRow(const Section §ion, const TextStyle &leftStyle, c * is also what leaves Natural sizing with a Fixed bridge as the only way to get a row * narrower than the section -- which is exactly where the row placement control shows. */ - if (section.bridgeFill != BridgeFill::Fixed) { - if (section.bridgeSpanEmpty && entry.text.isEmpty()) + if (fill != BridgeFill::Fixed) { + if (section.bridgeSpanEmpty && left.isEmpty()) leftWidth = 0.0; - if (section.bridgeSpanEmpty && entry.secondaryText.isEmpty()) + if (section.bridgeSpanEmpty && right.isEmpty()) rightWidth = 0.0; } @@ -859,7 +895,7 @@ BridgedRow placeBridgedRow(const Section §ion, const TextStyle &leftStyle, c BridgedRow row; row.leftWidth = leftWidth; row.rightWidth = rightWidth; - row.bridgeWidth = section.bridgeFill == BridgeFill::Fixed ? std::min(naturalBridge, slack) : slack; + row.bridgeWidth = fill == BridgeFill::Fixed ? std::min(naturalBridge, slack) : slack; const qreal rowWidth = leftWidth + row.bridgeWidth + rightWidth; row.leftX = contentX + alignOffset(section.bridgeRowAlign, contentWidth, rowWidth); @@ -951,6 +987,18 @@ PreparedBridge prepareTextBridge(const Section §ion, const TextStyle &style, PreparedBridge prepareBridge(const Section §ion, const TextStyle &style, BridgeArtCache *bridges, qreal width) { + if (bridgeTypeIsEmpty(section.bridgeType)) { + /* + * Nothing to draw, and deliberately nothing to measure either: an empty bridge + * reports no ascent and no height, so the row is exactly as tall as its own texts + * and its baseline is theirs. The space it occupies is still real -- the columns + * were placed around `bridgeMinGap` before this was ever called. + */ + PreparedBridge prepared; + prepared.font = makeFont(style); + return prepared; + } + if (!bridgeTypeUsesArt(section.bridgeType)) return prepareTextBridge(section, style, width); @@ -964,6 +1012,14 @@ PreparedBridge prepareBridge(const Section §ion, const TextStyle &style, Bri /* The width one copy of the bridge wants, which is what a Fixed bridge reserves from a row. */ qreal naturalBridgeWidth(const Section §ion, const TextStyle &style, BridgeArtCache *bridges) { + /* + * An empty bridge is nothing but its own width, which is the whole reason `bridgeMinGap` + * exists: it is what keeps the two texts of a naturally sized row off each other when + * there is no leader between them to do it. + */ + if (bridgeTypeIsEmpty(section.bridgeType)) + return std::max(0.0, section.bridgeMinGap); + if (!bridgeTypeUsesArt(section.bridgeType)) return naturalTextWidth(section.bridge, style); @@ -1089,16 +1145,6 @@ int layoutTitleSubtitle(QPainter *painter, const Section §ion, const TextSty return qCeil(cursor - y); } -/* - * The width a title/subtitle pair wants before any wrapping is imposed on it: whichever of its - * two lines is wider, since the two are laid out into the same column. - */ -qreal naturalPairWidth(const QString &title, const QString &subtitle, const TextStyle &titleStyle, - const TextStyle &subtitleStyle) -{ - return std::max(naturalTextWidth(title, titleStyle), naturalTextWidth(subtitle, subtitleStyle)); -} - /* Collects nothing, for the measure pass a caller makes before it knows where to place a pair. */ const auto kIgnoreBoxes = [](LayoutBox::Kind, const QRectF &) { /* deliberately nothing */ }; @@ -1554,6 +1600,16 @@ int layoutSection(QPainter *painter, const Section §ion, const Document &doc case SectionType::Bridged: { const TextStyle &rightStyle = document.effectiveSecondaryStyle(section); + /* + * The two subtitles, and the empty string that stands in for them when the section + * does not draw any. Standing one in rather than testing the flag at each use is what + * makes a row without subtitles go through the very same code as one with them: an + * empty line takes no height and no gap, so the pair collapses to exactly the single + * line a bridged row has always been, measured and placed identically. + */ + const TextStyle &leftSubtitleStyle = document.effectiveRowSubtitleStyle(section); + const TextStyle &rightSubtitleStyle = document.effectiveRowSecondarySubtitleStyle(section); + const QString noSubtitle; /* * The bridge's own ink over the row's own font. Everything below measures from the * fields the merge leaves alone -- the string is set in the row's face at the row's @@ -1563,8 +1619,14 @@ int layoutSection(QPainter *painter, const Section §ion, const Document &doc const qreal naturalBridge = naturalBridgeWidth(section, bridgeStyle, bridges); for (const Entry &entry : section.entries) { - const BridgedRow row = placeBridgedRow(section, style, rightStyle, entry, contentX, - contentWidth, naturalBridge); + const BridgedSide left{&entry.text, section.rowSubtitles ? &entry.subtitle : &noSubtitle, + &style, &leftSubtitleStyle}; + const BridgedSide right{&entry.secondaryText, + section.rowSubtitles ? &entry.secondarySubtitle : &noSubtitle, + &rightStyle, &rightSubtitleStyle}; + + const BridgedRow row = + placeBridgedRow(section, left, right, contentX, contentWidth, naturalBridge); const PreparedBridge bridge = prepareBridge(section, bridgeStyle, bridges, row.bridgeWidth); /* @@ -1572,9 +1634,21 @@ int layoutSection(QPainter *painter, const Section §ion, const Document &doc * keeps a leader running through the middle of the text when the two * sides are set at different sizes. It is anchored on whichever part * reaches lowest, so nothing climbs above the row into the one before it. + * + * A side carrying a subtitle is anchored on the line that ends up on *top* of + * its stack -- the same rule a bridged logo row follows, and for the same reason: + * adding a subtitle under a row should leave the leader exactly where it was + * rather than dragging it down to the middle of a two-line block. */ - const qreal leftAscent = firstBaseline(entry.text, style, row.leftWidth); - const qreal rightAscent = firstBaseline(entry.secondaryText, rightStyle, row.rightWidth); + const StackedLine leftTopLine = topStackedLine(section, *left.text, *left.subtitle, + *left.style, *left.subtitleStyle); + const StackedLine rightTopLine = topStackedLine(section, *right.text, *right.subtitle, + *right.style, *right.subtitleStyle); + + const qreal leftAscent = + firstBaseline(*leftTopLine.text, *leftTopLine.style, row.leftWidth); + const qreal rightAscent = + firstBaseline(*rightTopLine.text, *rightTopLine.style, row.rightWidth); const qreal bridgeAscent = bridge.ascent(section); const qreal baseline = std::max({leftAscent, rightAscent, bridgeAscent}); @@ -1582,15 +1656,20 @@ int layoutSection(QPainter *painter, const Section §ion, const Document &doc const qreal rightTop = y + baseline - rightAscent; const qreal bridgeTop = y + baseline - bridgeAscent; - const int leftHeight = - layoutText(painter, entry.text, style, row.leftX, leftTop, row.leftWidth); - const int rightHeight = layoutText(painter, entry.secondaryText, rightStyle, row.rightX, - rightTop, row.rightWidth); + /* + * Both sides go through the pair helper whether or not they carry a subtitle, + * which is what records their text boxes as well as drawing them. + */ + const int leftHeight = layoutTitleSubtitle(painter, section, *left.style, + *left.subtitleStyle, *left.text, *left.subtitle, + row.leftX, leftTop, row.leftWidth, record); + const int rightHeight = layoutTitleSubtitle(painter, section, *right.style, + *right.subtitleStyle, *right.text, + *right.subtitle, row.rightX, rightTop, + row.rightWidth, record); const int bridgeHeight = paintBridge(painter, bridge, section, bridgeStyle, bridges, row.bridgeX, bridgeTop, row.bridgeWidth); - record(LayoutBox::Kind::Text, QRectF(row.leftX, leftTop, row.leftWidth, leftHeight)); - record(LayoutBox::Kind::Text, QRectF(row.rightX, rightTop, row.rightWidth, rightHeight)); record(LayoutBox::Kind::Bridge, QRectF(row.bridgeX, bridgeTop, row.bridgeWidth, bridgeHeight)); int rowHeight = 1; diff --git a/src/ui/CsvImportDialog.cpp b/src/ui/CsvImportDialog.cpp index 2715901..9bf002f 100644 --- a/src/ui/CsvImportDialog.cpp +++ b/src/ui/CsvImportDialog.cpp @@ -64,7 +64,10 @@ const DelimiterOption kDelimiters[] = { } // namespace -CsvImportDialog::CsvImportDialog(SectionType type, QWidget *parent) : QDialog(parent), targetType(type) +CsvImportDialog::CsvImportDialog(SectionType type, bool subtitles, QWidget *parent) + : QDialog(parent), + targetType(type), + rowSubtitles(subtitles) { setWindowTitle(moduleText("Import.Title")); setModal(true); @@ -179,6 +182,11 @@ QVector CsvImportDialog::availableFields() const return {Field::Ignore, Field::LogoPath, Field::LogoHeight}; default: + /* Four, when a bridged row carries a subtitle under each of its two sides. */ + if (targetType == SectionType::Bridged && rowSubtitles) + return {Field::Ignore, Field::Text, Field::Subtitle, Field::SecondaryText, + Field::SecondarySubtitle}; + /* Two texts per entry -- a bridged row's two sides, or a title and its subtitle. */ if (sectionUsesSecondaryText(targetType)) return {Field::Ignore, Field::Text, Field::SecondaryText}; @@ -199,6 +207,10 @@ QString CsvImportDialog::fieldLabel(Field field) const : moduleText("Designer.Column.Text"); case Field::SecondaryText: return subtitles ? moduleText("Designer.Column.Subtitle") : moduleText("Designer.Column.Right"); + case Field::Subtitle: + return moduleText("Designer.Column.LeftSubtitle"); + case Field::SecondarySubtitle: + return moduleText("Designer.Column.RightSubtitle"); case Field::LogoPath: return moduleText("Designer.Column.Logo"); case Field::LogoHeight: @@ -390,6 +402,12 @@ void CsvImportDialog::accept() case Field::SecondaryText: entry.secondaryText = value; break; + case Field::Subtitle: + entry.subtitle = value; + break; + case Field::SecondarySubtitle: + entry.secondarySubtitle = value; + break; case Field::LogoPath: entry.logo.path = value; break; diff --git a/src/ui/CsvImportDialog.hpp b/src/ui/CsvImportDialog.hpp index 6ab2f02..b1ec572 100644 --- a/src/ui/CsvImportDialog.hpp +++ b/src/ui/CsvImportDialog.hpp @@ -45,7 +45,12 @@ class CsvImportDialog : public QDialog { Q_OBJECT public: - explicit CsvImportDialog(SectionType targetType, QWidget *parent = nullptr); + /* + * `rowSubtitles` is the Bridged section's own switch, which is what decides whether a row + * has two texts or four. It is passed rather than derived from the type because it is a + * property of the section being imported into, not of its type. + */ + explicit CsvImportDialog(SectionType targetType, bool rowSubtitles = false, QWidget *parent = nullptr); /* Valid once the dialog has been accepted. */ QVector entries() const { return imported; } @@ -53,7 +58,7 @@ class CsvImportDialog : public QDialog { private: /* A field of Entry that a CSV column can be mapped onto. */ - enum class Field { Ignore, Text, SecondaryText, LogoPath, LogoHeight }; + enum class Field { Ignore, Text, SecondaryText, Subtitle, SecondarySubtitle, LogoPath, LogoHeight }; void browse(); void reloadFile(); @@ -69,6 +74,7 @@ class CsvImportDialog : public QDialog { QString columnName(int column) const; SectionType targetType; + bool rowSubtitles = false; QLineEdit *pathEdit = nullptr; QPushButton *browseButton = nullptr; diff --git a/src/ui/SectionEditor.cpp b/src/ui/SectionEditor.cpp index 1cfc14c..cd35746 100644 --- a/src/ui/SectionEditor.cpp +++ b/src/ui/SectionEditor.cpp @@ -63,6 +63,50 @@ constexpr int kEntryTableMinimumHeight = 260; /* Width of a logo list's height column, which never holds more than four digits and a suffix. */ constexpr int kEntryHeightColumnWidth = 80; +/* + * Where an entry row keeps the whole of the entry it was written from. + * + * The table shows only the columns the section's type has a use for, and reading a section back + * rebuilds its entries from those columns -- so any field without a column of its own would be + * dropped by the next read, which is the one thing changing a type or turning a setting off must + * never do. Stashing the entry on the row's first cell costs a QVariantMap per row and makes every + * unshown field survive a type change, a subtitle toggle and a row being moved alike: a move swaps + * whole items, so the stash travels with the row it belongs to. + */ +constexpr int kEntryStashRole = Qt::UserRole + 1; + +QVariant entryStash(const Entry &entry) +{ + QVariantMap stash; + stash.insert(QStringLiteral("text"), entry.text); + stash.insert(QStringLiteral("secondary"), entry.secondaryText); + stash.insert(QStringLiteral("subtitle"), entry.subtitle); + stash.insert(QStringLiteral("secondary_subtitle"), entry.secondarySubtitle); + stash.insert(QStringLiteral("logo"), entry.logo.path); + stash.insert(QStringLiteral("logo_height"), entry.logo.maxHeight); + return stash; +} + +Entry entryFromStash(const QVariant &value) +{ + Entry entry; + if (!value.canConvert()) + return entry; + + const QVariantMap stash = value.toMap(); + entry.text = stash.value(QStringLiteral("text")).toString(); + entry.secondaryText = stash.value(QStringLiteral("secondary")).toString(); + entry.subtitle = stash.value(QStringLiteral("subtitle")).toString(); + entry.secondarySubtitle = stash.value(QStringLiteral("secondary_subtitle")).toString(); + entry.logo.path = stash.value(QStringLiteral("logo")).toString(); + + const int height = stash.value(QStringLiteral("logo_height")).toInt(); + if (height > 0) + entry.logo.maxHeight = height; + + return entry; +} + /* Height the divider's centre table asks for before it starts scrolling, in pixels. A centre * stack is a handful of pieces where an entry list is a cast, so it asks for less. */ constexpr int kCentreTableMinimumHeight = 150; @@ -673,6 +717,12 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) bridgeGap->setToolTip(moduleText("Designer.BridgeGap.Tip")); form->addRow(moduleText("Designer.BridgeGap"), bridgeGap); + bridgeMinGap = new QSpinBox(this); + bridgeMinGap->setRange(0, 2048); + bridgeMinGap->setSuffix(QStringLiteral(" px")); + bridgeMinGap->setToolTip(moduleText("Designer.BridgeMinGap.Tip")); + form->addRow(moduleText("Designer.BridgeMinGap"), bridgeMinGap); + bridgeTint = new QCheckBox(moduleText("Designer.BridgeTint"), this); form->addRow(QString(), bridgeTint); @@ -700,6 +750,10 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) bridgeSpanEmpty = new QCheckBox(moduleText("Designer.BridgeSpanEmpty"), this); form->addRow(QString(), bridgeSpanEmpty); + rowSubtitles = new QCheckBox(moduleText("Designer.RowSubtitles"), this); + rowSubtitles->setToolTip(moduleText("Designer.RowSubtitles.Tip")); + form->addRow(QString(), rowSubtitles); + /* * The divider's three artwork slots. Each picker is filled from the shape library filtered * by the slot it serves, so a shape offered in two places is one row in that table rather @@ -861,6 +915,25 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) bridgeStyleLayout->addWidget(bridgeStyle); outer->addWidget(bridgeStyleGroup); + /* + * The two subtitles of a bridged row. Two style groups rather than one, because the two + * sides of the row are already styled apart and a subtitle that could not follow the line it + * belongs under would be the one part of the row unable to. Neither is checkable: a subtitle + * is drawn whenever the row's subtitles are on and there is something in it to draw, which + * is what the entry's own cell already says. + */ + rowSubtitleStyleGroup = new QGroupBox(moduleText("Designer.RowSubtitleStyle"), this); + auto *rowSubtitleLayout = new QVBoxLayout(rowSubtitleStyleGroup); + rowSubtitleStyle = new StyleEditor(rowSubtitleStyleGroup); + rowSubtitleLayout->addWidget(rowSubtitleStyle); + outer->addWidget(rowSubtitleStyleGroup); + + rowSecondarySubtitleStyleGroup = new QGroupBox(moduleText("Designer.RowSecondarySubtitleStyle"), this); + auto *rowSecondarySubtitleLayout = new QVBoxLayout(rowSecondarySubtitleStyleGroup); + rowSecondarySubtitleStyle = new StyleEditor(rowSecondarySubtitleStyleGroup); + rowSecondarySubtitleLayout->addWidget(rowSecondarySubtitleStyle); + outer->addWidget(rowSecondarySubtitleStyleGroup); + entriesGroup = new QGroupBox(moduleText("Designer.Entries"), this); auto *entriesLayout = new QVBoxLayout(entriesGroup); @@ -978,7 +1051,7 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) const auto type = static_cast(typeBox->currentData().toInt()); applyTypeVisibility(type); - rebuildEntryTable(type); + rebuildEntryTable(type, rowSubtitles->isChecked()); emitChanged(); }); @@ -1001,6 +1074,7 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) connect(bridgeThickness, &QSpinBox::valueChanged, this, notify); connect(bridgeOffset, &QSpinBox::valueChanged, this, notify); connect(bridgeGap, &QSpinBox::valueChanged, this, notify); + connect(bridgeMinGap, &QSpinBox::valueChanged, this, notify); connect(bridgeSplit, &QSpinBox::valueChanged, this, notify); connect(bridgeRowAlign, &QComboBox::currentIndexChanged, this, notify); connect(bridgeSpanEmpty, &QCheckBox::toggled, this, notify); @@ -1055,13 +1129,16 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) connect(primaryStyle, &StyleEditor::changed, this, notify); connect(secondaryStyle, &StyleEditor::changed, this, notify); connect(bridgeStyle, &StyleEditor::changed, this, notify); + connect(rowSubtitleStyle, &StyleEditor::changed, this, notify); + connect(rowSecondarySubtitleStyle, &StyleEditor::changed, this, notify); /* * Preset edits are routed up to the designer, which owns the document the presets live * on. `presetOrigin` marks the editor mid-signal so the synchronous round trip back * through setPresets() leaves the fields being typed into alone. */ - for (StyleEditor *editor : {primaryStyle, secondaryStyle, bridgeStyle}) { + for (StyleEditor *editor : {primaryStyle, secondaryStyle, bridgeStyle, rowSubtitleStyle, + rowSecondarySubtitleStyle}) { connect(editor, &StyleEditor::presetSaveRequested, this, [this, editor](const QString &name, const TextStyle &style) { presetOrigin = editor; @@ -1075,6 +1152,22 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) }); } connect(secondaryGroup, &QGroupBox::toggled, this, notify); + /* + * The subtitle columns of the entry table come and go with this, so it rebuilds the table + * rather than only re-rendering. Reading the section back first is what carries what is + * already typed into the rebuild instead of taking the rebuild from stale entries. + */ + connect(rowSubtitles, &QCheckBox::toggled, this, [this] { + if (loading) + return; + + const Section held = section(); + const QSignalBlocker blocker(entryTable); + rebuildEntryTable(held.type, held.rowSubtitles); + writeEntriesToTable(held); + applyTypeVisibility(held.type); + emitChanged(); + }); connect(bridgeStyleGroup, &QGroupBox::toggled, this, notify); connect(entryTable, &QTableWidget::cellChanged, this, notify); connect(logoBrowse, &QToolButton::clicked, this, &SectionEditor::browseForSectionLogo); @@ -1118,12 +1211,14 @@ void SectionEditor::setSection(const Section &source) bridgeThickness->setValue(qRound(source.bridgeThickness)); bridgeOffset->setValue(qRound(source.bridgeOffset)); bridgeGap->setValue(qRound(source.bridgeGap)); + bridgeMinGap->setValue(qRound(source.bridgeMinGap)); bridgeTint->setChecked(source.bridgeTint); selectByData(bridgeFill, static_cast(source.bridgeFill)); selectByData(bridgeSizing, static_cast(source.bridgeSizing)); bridgeSplit->setValue(qRound(source.bridgeSplit * 100.0)); selectByData(bridgeRowAlign, static_cast(source.bridgeRowAlign)); bridgeSpanEmpty->setChecked(source.bridgeSpanEmpty); + rowSubtitles->setChecked(source.rowSubtitles); selectByData(dividerCap, static_cast(source.dividerCap)); dividerCapSvgPath->setText(source.dividerCapSvg); dividerMirrorEnds->setChecked(source.dividerMirrorEnds); @@ -1154,10 +1249,14 @@ void SectionEditor::setSection(const Section &source) primaryStyle->setStyle(source.style); secondaryStyle->setStyle(source.secondaryStyle); bridgeStyle->setStyle(source.bridgeStyle); + rowSubtitleStyle->setStyle(source.rowSubtitleStyle); + rowSecondarySubtitleStyle->setStyle(source.rowSecondarySubtitleStyle); /* After setStyle, so a bound preset's values win over the section's own copy. */ primaryStyle->setPresets(presets, source.stylePresetName); secondaryStyle->setPresets(presets, source.secondaryStylePresetName); bridgeStyle->setPresets(presets, source.bridgeStylePresetName); + rowSubtitleStyle->setPresets(presets, source.rowSubtitleStylePresetName); + rowSecondarySubtitleStyle->setPresets(presets, source.rowSecondarySubtitleStylePresetName); secondaryGroup->setChecked(source.useSecondaryStyle); bridgeStyleGroup->setChecked(source.useBridgeStyle); @@ -1168,7 +1267,7 @@ void SectionEditor::setSection(const Section &source) */ writeCentreToTable(source); applyTypeVisibility(source.type); - rebuildEntryTable(source.type); + rebuildEntryTable(source.type, source.rowSubtitles); writeEntriesToTable(source); /* After the tables, since what it asks about includes the artwork they hold. */ refreshLogoPlayback(); @@ -1206,12 +1305,14 @@ Section SectionEditor::section() const result.bridgeThickness = bridgeThickness->value(); result.bridgeOffset = bridgeOffset->value(); result.bridgeGap = bridgeGap->value(); + result.bridgeMinGap = bridgeMinGap->value(); result.bridgeTint = bridgeTint->isChecked(); result.bridgeFill = static_cast(bridgeFill->currentData().toInt()); result.bridgeSizing = static_cast(bridgeSizing->currentData().toInt()); result.bridgeSplit = bridgeSplit->value() / 100.0; result.bridgeRowAlign = static_cast(bridgeRowAlign->currentData().toInt()); result.bridgeSpanEmpty = bridgeSpanEmpty->isChecked(); + result.rowSubtitles = rowSubtitles->isChecked(); result.dividerCap = static_cast(dividerCap->currentData().toInt()); result.dividerCapSvg = dividerCapSvgPath->text(); result.dividerMirrorEnds = dividerMirrorEnds->isChecked(); @@ -1241,11 +1342,15 @@ Section SectionEditor::section() const result.style = primaryStyle->style(); result.secondaryStyle = secondaryStyle->style(); result.bridgeStyle = bridgeStyle->style(); + result.rowSubtitleStyle = rowSubtitleStyle->style(); + result.rowSecondarySubtitleStyle = rowSecondarySubtitleStyle->style(); result.useSecondaryStyle = secondaryGroup->isChecked(); result.useBridgeStyle = bridgeStyleGroup->isChecked(); result.stylePresetName = primaryStyle->presetName(); result.secondaryStylePresetName = secondaryStyle->presetName(); result.bridgeStylePresetName = bridgeStyle->presetName(); + result.rowSubtitleStylePresetName = rowSubtitleStyle->presetName(); + result.rowSecondarySubtitleStylePresetName = rowSecondarySubtitleStyle->presetName(); readEntriesFromTable(&result); readCentreFromTable(&result); @@ -1263,7 +1368,8 @@ void SectionEditor::setPresets(const QVector &newPresets) { presets = newPresets; - for (StyleEditor *editor : {primaryStyle, secondaryStyle, bridgeStyle}) + for (StyleEditor *editor : + {primaryStyle, secondaryStyle, bridgeStyle, rowSubtitleStyle, rowSecondarySubtitleStyle}) editor->setPresets(presets, editor->presetName(), editor != presetOrigin); } @@ -1298,7 +1404,14 @@ void SectionEditor::applyTypeVisibility(SectionType type) form->setRowVisible(logoSide, logoBesideText); form->setRowVisible(logoGap, logoBesideText); const bool bridged = type == SectionType::Bridged; - const auto fill = static_cast(bridgeFill->currentData().toInt()); + /* + * The fill the row will really be laid out with, not the one the combo happens to hold: an + * empty bridge is always Fixed, and the rows below that turn on the distinction have to + * agree with the renderer about which mode is in force or they show settings that do nothing. + */ + const auto fill = bridgeTypeIsEmpty(static_cast(bridgeType->currentData().toInt())) + ? BridgeFill::Fixed + : static_cast(bridgeFill->currentData().toInt()); const auto sizing = static_cast(bridgeSizing->currentData().toInt()); const auto placement = static_cast(logoPlacement->currentData().toInt()); @@ -1309,17 +1422,26 @@ void SectionEditor::applyTypeVisibility(SectionType type) const auto bridgeArt = static_cast(bridgeType->currentData().toInt()); const bool drawnArt = usesBridge && bridgeTypeUsesArt(bridgeArt); const bool artFromFile = usesBridge && bridgeTypeUsesFile(bridgeArt); + /* + * An empty bridge has one setting of its own -- how much room it keeps -- and no use for the + * rest. Its fill row goes with them: nothing is drawn, so the three modes would differ only + * in how the text columns come out, which is not what a control called Fill promises. The + * renderer lays it out as Fixed to match (see effectiveBridgeFill). + */ + const bool emptyBridge = usesBridge && bridgeTypeIsEmpty(bridgeArt); form->setRowVisible(logoPlacement, logoBesideText); form->setRowVisible(bridgeType, usesBridge); - form->setRowVisible(bridgeEdit, usesBridge && !drawnArt); + form->setRowVisible(bridgeEdit, usesBridge && !drawnArt && !emptyBridge); form->setRowVisible(bridgeSvgPath->parentWidget(), artFromFile); form->setRowVisible(bridgeThickness, drawnArt); form->setRowVisible(bridgeOffset, drawnArt); form->setRowVisible(bridgeGap, drawnArt); + /* Only a Bridged section reserves the gap between two columns; a logo row spans what is left. */ + form->setRowVisible(bridgeMinGap, emptyBridge && bridged); /* The built-in tiles are drawn white to be tinted; only a user's file has colours to keep. */ form->setRowVisible(bridgeTint, artFromFile); - form->setRowVisible(bridgeFill, usesBridge); + form->setRowVisible(bridgeFill, usesBridge && !emptyBridge); /* Column sizing and row placement describe two texts, so they stay with that type. */ form->setRowVisible(bridgeSizing, bridged); /* The split is the tab stop; with Natural sizing the text decides where things land. */ @@ -1331,6 +1453,16 @@ void SectionEditor::applyTypeVisibility(SectionType type) form->setRowVisible(bridgeRowAlign, bridged && sizing == BridgeSizing::Natural && fill == BridgeFill::Fixed); /* A fixed bridge has nothing to run into the space an empty column would free. */ form->setRowVisible(bridgeSpanEmpty, bridged && fill != BridgeFill::Fixed); + form->setRowVisible(rowSubtitles, bridged); + + /* + * The two subtitle styles show only while the row actually draws subtitles. They are read + * back whether or not they are on screen -- like every other hidden row here -- so turning + * the subtitles off and on again returns the styles the user set rather than the defaults. + */ + const bool rowSubtitlesOn = bridged && rowSubtitles->isChecked(); + rowSubtitleStyleGroup->setVisible(rowSubtitlesOn); + rowSecondarySubtitleStyleGroup->setVisible(rowSubtitlesOn); /* * The divider's own rows. A slot's file picker follows the shape picked for it, and the * second end follows the mirror toggle: a divider whose two ends are the same shape has one @@ -1373,9 +1505,16 @@ void SectionEditor::applyTypeVisibility(SectionType type) form->setRowVisible(columnGap, hasColumns); form->setRowVisible(fillOrder, hasColumns); form->setRowVisible(entryGap, hasEntries); + /* + * The pair's own two settings apply wherever anything is really stacked, which for a bridged + * row is a choice rather than a property of the type -- so this asks about the section + * being edited and not only about its type. `secondaryGroup`'s title stays keyed on the + * type: for a bridged row the second style is still the right-hand *text*, subtitles or not. + */ const bool hasSubtitles = sectionUsesSubtitles(type); - form->setRowVisible(subtitleGap, hasSubtitles); - form->setRowVisible(subtitleOrder, hasSubtitles); + const bool stacksSubtitles = hasSubtitles || (bridged && rowSubtitles->isChecked()); + form->setRowVisible(subtitleGap, stacksSubtitles); + form->setRowVisible(subtitleOrder, stacksSubtitles); form->setRowVisible(spacerHeight, type == SectionType::Spacer); /* @@ -1419,7 +1558,7 @@ void SectionEditor::applyTypeVisibility(SectionType type) outerLayout->setStretch(trailingStretchIndex, hasEntries || divider ? 0 : 1); } -void SectionEditor::rebuildEntryTable(SectionType type) +void SectionEditor::rebuildEntryTable(SectionType type, bool rowSubtitles) { const QSignalBlocker blocker(entryTable); @@ -1428,6 +1567,20 @@ void SectionEditor::rebuildEntryTable(SectionType type) switch (type) { case SectionType::Bridged: + /* + * Each side of the row gains a column of its own when the section draws subtitles, + * with the pair kept side by side rather than the two subtitles gathered at the end: + * a row is read across, and a subtitle belongs beside the line it sits under. + */ + if (rowSubtitles) { + entryTable->setColumnCount(4); + entryTable->setHorizontalHeaderLabels({moduleText("Designer.Column.Left"), + moduleText("Designer.Column.LeftSubtitle"), + moduleText("Designer.Column.Right"), + moduleText("Designer.Column.RightSubtitle")}); + break; + } + entryTable->setColumnCount(2); entryTable->setHorizontalHeaderLabels( {moduleText("Designer.Column.Left"), moduleText("Designer.Column.Right")}); @@ -1479,18 +1632,29 @@ void SectionEditor::writeEntriesToTable(const Section &source) { const QSignalBlocker blocker(entryTable); const bool logoMode = sectionUsesLogos(source.type) && sectionUsesEntries(source.type); + const bool subtitleColumns = source.type == SectionType::Bridged && source.rowSubtitles; entryTable->setRowCount(source.entries.size()); for (int row = 0; row < source.entries.size(); ++row) { const Entry &entry = source.entries.at(row); + auto *first = new QTableWidgetItem(logoMode ? entry.logo.path : entry.text); + /* The whole entry travels with the row; see kEntryStashRole. */ + first->setData(kEntryStashRole, entryStash(entry)); + entryTable->setItem(row, 0, first); + if (logoMode) { - entryTable->setItem(row, 0, new QTableWidgetItem(entry.logo.path)); entryTable->setItem(row, 1, new QTableWidgetItem(QString::number(entry.logo.maxHeight))); continue; } - entryTable->setItem(row, 0, new QTableWidgetItem(entry.text)); + if (subtitleColumns) { + entryTable->setItem(row, 1, new QTableWidgetItem(entry.subtitle)); + entryTable->setItem(row, 2, new QTableWidgetItem(entry.secondaryText)); + entryTable->setItem(row, 3, new QTableWidgetItem(entry.secondarySubtitle)); + continue; + } + if (sectionUsesSecondaryText(source.type)) entryTable->setItem(row, 1, new QTableWidgetItem(entry.secondaryText)); } @@ -1499,6 +1663,7 @@ void SectionEditor::writeEntriesToTable(const Section &source) void SectionEditor::readEntriesFromTable(Section *target) const { const bool logoMode = sectionUsesLogos(target->type) && sectionUsesEntries(target->type); + const bool subtitleColumns = target->type == SectionType::Bridged && target->rowSubtitles; QVector entries; entries.reserve(entryTable->rowCount()); @@ -1509,11 +1674,25 @@ void SectionEditor::readEntriesFromTable(Section *target) const return item ? item->text() : QString(); }; - Entry entry; + /* + * Everything the row was last written from, then whatever its visible columns now + * hold on top of it. That order is what makes reading a section non-destructive: a + * field the table is not currently showing -- a subtitle whose toggle is off, the + * text behind a type that shows logos -- comes back from the stash rather than + * coming back empty. + */ + const QTableWidgetItem *first = entryTable->item(row, 0); + Entry entry = first ? entryFromStash(first->data(kEntryStashRole)) : Entry(); + if (logoMode) { entry.logo.path = cell(0); const int height = cell(1).toInt(); entry.logo.maxHeight = height > 0 ? height : 96; + } else if (subtitleColumns) { + entry.text = cell(0); + entry.subtitle = cell(1); + entry.secondaryText = cell(2); + entry.secondarySubtitle = cell(3); } else { entry.text = cell(0); if (sectionUsesSecondaryText(target->type)) @@ -1926,7 +2105,15 @@ void SectionEditor::browseForEntryLogo() if (path.isEmpty()) return; - entryTable->setItem(row, 0, new QTableWidgetItem(path)); + /* + * Set on the item rather than replacing it, so the row keeps the entry stashed on its first + * cell -- a new item would come with none and the row's unshown fields would go with it. + */ + if (QTableWidgetItem *item = entryTable->item(row, 0)) + item->setText(path); + else + entryTable->setItem(row, 0, new QTableWidgetItem(path)); + if (!entryTable->item(row, 1)) entryTable->setItem(row, 1, new QTableWidgetItem(QStringLiteral("96"))); @@ -1937,7 +2124,7 @@ void SectionEditor::importCsv() { const auto type = static_cast(typeBox->currentData().toInt()); - CsvImportDialog dialog(type, this); + CsvImportDialog dialog(type, rowSubtitles->isChecked(), this); if (dialog.exec() != QDialog::Accepted) return; diff --git a/src/ui/SectionEditor.hpp b/src/ui/SectionEditor.hpp index f7dda8a..2eda049 100644 --- a/src/ui/SectionEditor.hpp +++ b/src/ui/SectionEditor.hpp @@ -194,7 +194,12 @@ class SectionEditor : public QWidget { void refreshLogoPlayback(); /* The playback settings the controls currently describe. */ LogoPlayback currentLogoPlayback() const; - void rebuildEntryTable(SectionType type); + /* + * `rowSubtitles` is passed rather than read off the checkbox because the table is rebuilt + * from a section being loaded as well as from a type being picked, and in the first case the + * widgets have not been written yet. + */ + void rebuildEntryTable(SectionType type, bool rowSubtitles); void readEntriesFromTable(Section *target) const; void writeEntriesToTable(const Section &source); @@ -264,12 +269,16 @@ class SectionEditor : public QWidget { QSpinBox *bridgeThickness = nullptr; QSpinBox *bridgeOffset = nullptr; QSpinBox *bridgeGap = nullptr; + /* Empty bridges only: how much room the gap keeps when nothing is drawn in it. */ + QSpinBox *bridgeMinGap = nullptr; QCheckBox *bridgeTint = nullptr; QComboBox *bridgeFill = nullptr; QComboBox *bridgeSizing = nullptr; QSpinBox *bridgeSplit = nullptr; QComboBox *bridgeRowAlign = nullptr; QCheckBox *bridgeSpanEmpty = nullptr; + /* Bridged rows only: draw a second line under each side of every row. */ + QCheckBox *rowSubtitles = nullptr; QComboBox *dividerCap = nullptr; QLineEdit *dividerCapSvgPath = nullptr; @@ -310,6 +319,11 @@ class SectionEditor : public QWidget { /* Shown for the two shapes that draw a bridge, and only ever edits that bridge's ink. */ QGroupBox *bridgeStyleGroup = nullptr; StyleEditor *bridgeStyle = nullptr; + /* The two subtitles of a bridged row, shown only while that row draws them. */ + QGroupBox *rowSubtitleStyleGroup = nullptr; + StyleEditor *rowSubtitleStyle = nullptr; + QGroupBox *rowSecondarySubtitleStyleGroup = nullptr; + StyleEditor *rowSecondarySubtitleStyle = nullptr; QGroupBox *entriesGroup = nullptr; QTableWidget *entryTable = nullptr; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 701b2e9..aa5b3f6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,6 +15,7 @@ target_sources( harness/Probe.hpp harness/main.cpp suites/AnimatedLogos.cpp + suites/BridgedRows.cpp suites/Fonts.cpp suites/Layout.cpp suites/LogoRows.cpp diff --git a/tests/harness/Fixtures.cpp b/tests/harness/Fixtures.cpp index b167e17..b6fc88b 100644 --- a/tests/harness/Fixtures.cpp +++ b/tests/harness/Fixtures.cpp @@ -342,15 +342,17 @@ const QVector &scenes() { Section bridged = Section::makeDefault(SectionType::Bridged); - bridged.entries = {Entry{QStringLiteral("Director"), QStringLiteral("Jane Doe"), {}}, + bridged.entries = {Entry{QStringLiteral("Director"), QStringLiteral("Jane Doe"), {}, {}, {}}, Entry{QStringLiteral("Director of Photography"), QStringLiteral("A Rather Longer Name"), + {}, + {}, {}}, - Entry{QStringLiteral("Editor"), QStringLiteral("R Singh"), {}}}; + Entry{QStringLiteral("Editor"), QStringLiteral("R Singh"), {}, {}, {}}}; Section pairs = Section::makeDefault(SectionType::TitleSubtitleList); - pairs.entries = {Entry{QStringLiteral("Producer"), QStringLiteral("Chris Nakamura"), {}}, - Entry{QStringLiteral("Line Producer"), QStringLiteral("Sam Oyelaran"), {}}}; + pairs.entries = {Entry{QStringLiteral("Producer"), QStringLiteral("Chris Nakamura"), {}, {}, {}}, + Entry{QStringLiteral("Line Producer"), QStringLiteral("Sam Oyelaran"), {}, {}, {}}}; add(QStringLiteral("lists"), QStringLiteral("A bridged list over a title/subtitle list"), documentWith({bridged, pairs})); diff --git a/tests/suites/BridgedRows.cpp b/tests/suites/BridgedRows.cpp new file mode 100644 index 0000000..f185a0d --- /dev/null +++ b/tests/suites/BridgedRows.cpp @@ -0,0 +1,223 @@ +/* +Closing Time +Copyright (C) 2026 Voidscape Development + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include "harness/Fixtures.hpp" +#include "harness/Harness.hpp" +#include "harness/Probe.hpp" + +using namespace closingtime; +using namespace closingtime::test; + +namespace { + +/* A one-row bridged section with nothing but its own content in the document. */ +Section bridgedRow(const QString &left, const QString &right) +{ + Section section = unpadded(SectionType::Bridged); + section.marginX = 0; + section.entries = {Entry{left, right, {}, {}, {}}}; + return section; +} + +/* The two text columns of the first row, left first. */ +QPair textColumns(const Document &document) +{ + const QVector texts = boxesOf(document, LayoutBox::Kind::Text); + if (texts.size() < 2) + return {}; + + return {texts.at(0), texts.at(1)}; +} + +} // namespace + +CT_SUITE(bridge_none_gap, "An empty bridge keeping its two texts apart") +{ + /* + * Natural sizing is where an empty bridge could go wrong: each column takes only the width + * its own text needs, and with no leader between them there is nothing left to hold them + * off each other except the minimum this type carries for exactly that reason. + */ + for (int minGap : {0, 24, 120}) { + const Context context(QStringLiteral("minGap=%1").arg(minGap)); + + Section section = bridgedRow(QStringLiteral("Director"), QStringLiteral("Jane Doe")); + section.bridgeType = BridgeType::None; + section.bridgeSizing = BridgeSizing::Natural; + section.bridgeFill = BridgeFill::Fixed; + section.bridgeMinGap = minGap; + + const Document document = documentWith(section); + const auto [left, right] = textColumns(document); + + check(!left.isNull() && !right.isNull(), "both columns placed"); + checkNear(right.left() - left.right(), minGap, 1.0, "the gap is the minimum asked for"); + + /* + * Nothing is drawn in it. Measured across the middle of the gap rather than its full + * width, because a glyph may ink a pixel or two outside the column it was laid out + * in -- a negative left side bearing is ordinary type design, not a bridge. + */ + if (minGap >= 8) { + const qreal inset = 4.0; + const QRectF middle(left.right() + inset, left.top(), minGap - inset * 2.0, + std::max(1.0, left.height())); + check(inkOf(renderImage(document), middle).isEmpty(), "nothing is drawn in the gap"); + } + } +} + +CT_SUITE(bridge_none_fill_ignored, "An empty bridge laid out the same whatever its fill says") +{ + /* + * `bridgeFill` is hidden for an empty bridge and forced to Fixed by the model, so all three + * settings have to produce the identical row -- otherwise the setting is doing something + * invisible from a control the user cannot see. + */ + Section section = bridgedRow(QStringLiteral("Editor"), QStringLiteral("R Singh")); + section.bridgeType = BridgeType::None; + section.bridgeSizing = BridgeSizing::Natural; + section.bridgeMinGap = 40; + + section.bridgeFill = BridgeFill::Fixed; + const auto [fixedLeft, fixedRight] = textColumns(documentWith(section)); + + for (BridgeFill fill : {BridgeFill::Repeat, BridgeFill::Stretch}) { + const Context context(QStringLiteral("fill=%1").arg(bridgeFillId(fill))); + + Section filled = section; + filled.bridgeFill = fill; + const auto [left, right] = textColumns(documentWith(filled)); + + checkNear(left.left(), fixedLeft.left(), 0.5, "left column unmoved"); + checkNear(right.left(), fixedRight.left(), 0.5, "right column unmoved"); + } + + /* And the model agrees, so nothing downstream has to special-case it a second time. */ + Section stretched = section; + stretched.bridgeFill = BridgeFill::Stretch; + check(effectiveBridgeFill(stretched) == BridgeFill::Fixed, "an empty bridge is laid out as Fixed"); + stretched.bridgeType = BridgeType::Dots; + check(effectiveBridgeFill(stretched) == BridgeFill::Stretch, "and a drawn one keeps its own fill"); +} + +CT_SUITE(bridge_none_split, "An empty bridge reserving its gap under Split sizing too") +{ + Section section = bridgedRow(QStringLiteral("Director of Photography"), QStringLiteral("A Longer Name")); + section.bridgeType = BridgeType::None; + section.bridgeSizing = BridgeSizing::Split; + section.bridgeSplit = 0.5; + section.bridgeMinGap = 60; + + const Document document = documentWith(section); + const auto [left, right] = textColumns(document); + + check(!left.isNull() && !right.isNull(), "both columns placed"); + checkNear(right.left() - left.right(), 60.0, 1.5, "the reserved gap is the minimum"); + /* Split sizing still fills the section: the row reaches both edges of the content box. */ + checkNear(left.left() + left.width() + 60.0 + right.width(), left.left() + document.width, 2.0, + "the row still spans the section"); +} + +CT_SUITE(bridge_subtitles_stack, "Subtitles under each side of a bridged row") +{ + Section plain = bridgedRow(QStringLiteral("Director"), QStringLiteral("Jane Doe")); + plain.bridgeType = BridgeType::Dots; + plain.bridgeFill = BridgeFill::Repeat; + + Section stacked = plain; + stacked.rowSubtitles = true; + stacked.entries = {Entry{QStringLiteral("Director"), QStringLiteral("Jane Doe"), + QStringLiteral("second unit"), QStringLiteral("BSC"), {}}}; + + const int plainHeight = measure(documentWith(plain)); + const int stackedHeight = measure(documentWith(stacked)); + check(stackedHeight > plainHeight, "a subtitle under each side makes the row taller"); + + /* + * The leader hangs off the top line of the row, the way it does for a bridged logo row: a + * subtitle added under a name must not drag the dots down into the middle of the block. + */ + const QRectF plainBridge = boxOf(documentWith(plain), LayoutBox::Kind::Bridge); + const QRectF stackedBridge = boxOf(documentWith(stacked), LayoutBox::Kind::Bridge); + check(!plainBridge.isNull() && !stackedBridge.isNull(), "both rows drew a bridge"); + checkNear(stackedBridge.top(), plainBridge.top(), 1.0, "the leader stays on the top line"); + + /* + * Four text boxes now: two lines a side, in the order left title, left subtitle, right + * title, right subtitle. The two titles are set at the same size, so anchoring each side on + * its own top line puts them on the same line as each other -- a side anchored on the wrong + * line of its stack drops by the difference between the two ascents. + */ + const QVector lines = boxesOf(documentWith(stacked), LayoutBox::Kind::Text); + checkEq(lines.size(), 4, "two lines on each side"); + if (lines.size() == 4) { + checkNear(lines.at(0).top(), lines.at(2).top(), 1.0, "both sides start on the same line"); + check(lines.at(1).top() > lines.at(0).top(), "the left subtitle sits under its own line"); + check(lines.at(3).top() > lines.at(2).top(), "the right subtitle sits under its own line"); + } + checkEq(boxesOutsideContent(documentWith(stacked)), 0, "nothing placed outside the content box"); +} + +CT_SUITE(bridge_subtitles_optional, "A bridged row with the subtitles on but nothing in them") +{ + /* + * The whole point of the switch is that it costs nothing until a row uses it: an entry with + * empty subtitles has to measure, place and draw exactly as one from before the feature. + */ + Section plain = bridgedRow(QStringLiteral("Editor"), QStringLiteral("R Singh")); + Section empty = plain; + empty.rowSubtitles = true; + + checkEq(measure(documentWith(empty)), measure(documentWith(plain)), "an empty pair is one line tall"); + + const auto [plainLeft, plainRight] = textColumns(documentWith(plain)); + const auto [emptyLeft, emptyRight] = textColumns(documentWith(empty)); + checkNear(emptyLeft.top(), plainLeft.top(), 0.5, "the left line has not moved"); + checkNear(emptyRight.left(), plainRight.left(), 0.5, "the right column has not moved"); + + /* One side filled in, the other not: only the filled side grows. */ + Section half = empty; + half.entries = {Entry{QStringLiteral("Editor"), QStringLiteral("R Singh"), QStringLiteral("assembly"), {}, {}}}; + checkEq(boxesOf(documentWith(half), LayoutBox::Kind::Text).size(), 3, "three lines, not four"); +} + +CT_SUITE(bridge_subtitles_columns, "A bridged column sized from the wider of its two lines") +{ + /* + * Under Natural sizing a column takes what its text needs -- which, once a side is a pair, + * is the wider of the two lines. A column measured from the title alone would wrap the + * longer subtitle underneath it inside a column that was never sized for it. + */ + Section section = bridgedRow(QStringLiteral("Ed"), QStringLiteral("R Singh")); + section.bridgeType = BridgeType::None; + section.bridgeSizing = BridgeSizing::Natural; + section.bridgeMinGap = 20; + section.rowSubtitles = true; + + const qreal narrow = textColumns(documentWith(section)).first.width(); + + section.entries = {Entry{QStringLiteral("Ed"), QStringLiteral("R Singh"), + QStringLiteral("a considerably longer subtitle than the line above it"), {}, {}}}; + const QRectF wide = textColumns(documentWith(section)).first; + + check(wide.width() > narrow, "the column grew to hold the wider line"); + + /* And the subtitle really fits on one line in it, rather than wrapping inside its own column. */ + checkEq(boxesOf(documentWith(section), LayoutBox::Kind::Text).size(), 3, "no line wrapped into a fourth box"); +} diff --git a/tests/suites/Fonts.cpp b/tests/suites/Fonts.cpp index 9c06e62..4d3f35e 100644 --- a/tests/suites/Fonts.cpp +++ b/tests/suites/Fonts.cpp @@ -345,7 +345,7 @@ CT_SUITE(fonts_used_families, "Which families a roll is actually set in") * there is never drawn with -- reporting it would send the user after a font nothing uses. */ Section bridged = unpadded(SectionType::Bridged); - bridged.entries = {Entry{QStringLiteral("Director"), QStringLiteral("Jane Doe"), LogoRef()}}; + bridged.entries = {Entry{QStringLiteral("Director"), QStringLiteral("Jane Doe"), {}, {}, LogoRef()}}; bridged.style.family = QStringLiteral("Row Face"); bridged.secondaryStyle.family = QStringLiteral("Row Face"); bridged.bridgeStyle.family = QStringLiteral("Bridge Face"); diff --git a/tests/suites/Layout.cpp b/tests/suites/Layout.cpp index 628da9f..d03db8d 100644 --- a/tests/suites/Layout.cpp +++ b/tests/suites/Layout.cpp @@ -139,7 +139,7 @@ CT_SUITE(layout_tiling, "A roll long enough to be cut into several tiles") QVector
sections; for (int i = 0; i < 60; ++i) { Section section = Section::makeDefault(SectionType::TextList); - section.entries = {Entry{QStringLiteral("Name %1").arg(i), {}, {}}}; + section.entries = {Entry{QStringLiteral("Name %1").arg(i), {}, {}, {}, {}}}; sections.append(section); } diff --git a/tests/suites/Persistence.cpp b/tests/suites/Persistence.cpp index ce3d87c..e5549c0 100644 --- a/tests/suites/Persistence.cpp +++ b/tests/suites/Persistence.cpp @@ -56,6 +56,8 @@ Section distinctive(SectionType type) section.bridgeSplit = 0.25; section.bridgeRowAlign = HAlign::Left; section.bridgeSpanEmpty = true; + section.bridgeMinGap = 33.5; + section.rowSubtitles = true; section.dividerCap = DividerShape::Diamond; section.dividerMirrorEnds = false; section.dividerEndCap = DividerShape::Dot; @@ -77,6 +79,10 @@ Section distinctive(SectionType type) section.stylePresetName = QStringLiteral("preset a"); section.secondaryStylePresetName = QStringLiteral("preset b"); section.bridgeStylePresetName = QStringLiteral("preset c"); + section.rowSubtitleStylePresetName = QStringLiteral("preset d"); + section.rowSecondarySubtitleStylePresetName = QStringLiteral("preset e"); + section.rowSubtitleStyle.pixelSize = 19; + section.rowSecondarySubtitleStyle.pixelSize = 21; section.paddingTop = 21; section.paddingBottom = 23; section.marginX = 29; @@ -84,7 +90,8 @@ Section distinctive(SectionType type) section.sectionAlign = HAlign::Right; section.spacerHeight = 313; section.visible = false; - section.entries = {Entry{QStringLiteral("one"), QStringLiteral("two"), LogoRef{QStringLiteral("/e.png"), 61}}}; + section.entries = {Entry{QStringLiteral("one"), QStringLiteral("two"), QStringLiteral("three"), + QStringLiteral("four"), LogoRef{QStringLiteral("/e.png"), 61}}}; return section; } @@ -114,6 +121,8 @@ void compare(const Section &loaded, const Section &original) checkNear(loaded.bridgeSplit, original.bridgeSplit, 0.001, "bridgeSplit"); check(loaded.bridgeRowAlign == original.bridgeRowAlign, "bridgeRowAlign"); check(loaded.bridgeSpanEmpty == original.bridgeSpanEmpty, "bridgeSpanEmpty"); + checkNear(loaded.bridgeMinGap, original.bridgeMinGap, 0.001, "bridgeMinGap"); + check(loaded.rowSubtitles == original.rowSubtitles, "rowSubtitles"); check(loaded.dividerCap == original.dividerCap, "dividerCap"); check(loaded.dividerMirrorEnds == original.dividerMirrorEnds, "dividerMirrorEnds"); check(loaded.dividerEndCap == original.dividerEndCap, "dividerEndCap"); @@ -134,6 +143,12 @@ void compare(const Section &loaded, const Section &original) checkEq(loaded.stylePresetName, original.stylePresetName, "stylePresetName"); checkEq(loaded.secondaryStylePresetName, original.secondaryStylePresetName, "secondaryStylePresetName"); checkEq(loaded.bridgeStylePresetName, original.bridgeStylePresetName, "bridgeStylePresetName"); + checkEq(loaded.rowSubtitleStylePresetName, original.rowSubtitleStylePresetName, "rowSubtitleStylePresetName"); + checkEq(loaded.rowSecondarySubtitleStylePresetName, original.rowSecondarySubtitleStylePresetName, + "rowSecondarySubtitleStylePresetName"); + checkEq(loaded.rowSubtitleStyle.pixelSize, original.rowSubtitleStyle.pixelSize, "rowSubtitleStyle.pixelSize"); + checkEq(loaded.rowSecondarySubtitleStyle.pixelSize, original.rowSecondarySubtitleStyle.pixelSize, + "rowSecondarySubtitleStyle.pixelSize"); checkEq(loaded.paddingTop, original.paddingTop, "paddingTop"); checkEq(loaded.paddingBottom, original.paddingBottom, "paddingBottom"); checkEq(loaded.marginX, original.marginX, "marginX"); @@ -147,6 +162,10 @@ void compare(const Section &loaded, const Section &original) checkEq(loaded.entries.at(i).text, original.entries.at(i).text, QStringLiteral("entry %1 text").arg(i)); checkEq(loaded.entries.at(i).secondaryText, original.entries.at(i).secondaryText, QStringLiteral("entry %1 secondaryText").arg(i)); + checkEq(loaded.entries.at(i).subtitle, original.entries.at(i).subtitle, + QStringLiteral("entry %1 subtitle").arg(i)); + checkEq(loaded.entries.at(i).secondarySubtitle, original.entries.at(i).secondarySubtitle, + QStringLiteral("entry %1 secondarySubtitle").arg(i)); checkEq(loaded.entries.at(i).logo.path, original.entries.at(i).logo.path, QStringLiteral("entry %1 logo").arg(i)); } diff --git a/tests/suites/StyleLibrary.cpp b/tests/suites/StyleLibrary.cpp index db2393e..5017501 100644 --- a/tests/suites/StyleLibrary.cpp +++ b/tests/suites/StyleLibrary.cpp @@ -90,7 +90,7 @@ Document boundDocument(const QString &presetName, const TextStyle &style, bool l Document boundEverywhere(const QString &presetName, bool linked) { Section bridged = unpadded(SectionType::Bridged); - bridged.entries = {Entry{QStringLiteral("Director"), QStringLiteral("Jane Doe"), LogoRef()}}; + bridged.entries = {Entry{QStringLiteral("Director"), QStringLiteral("Jane Doe"), {}, {}, LogoRef()}}; bridged.stylePresetName = presetName; bridged.secondaryStylePresetName = presetName; bridged.bridgeStylePresetName = presetName; diff --git a/tests/suites/SubtitleHeadings.cpp b/tests/suites/SubtitleHeadings.cpp index 7e620fd..e8862ff 100644 --- a/tests/suites/SubtitleHeadings.cpp +++ b/tests/suites/SubtitleHeadings.cpp @@ -171,7 +171,7 @@ CT_SUITE(stack_shared_with_lists, "A heading's pair and a list entry's pair bein heading.secondaryText = QStringLiteral("Full Name"); Section list = unpadded(SectionType::TitleSubtitleList); - list.entries = {Entry{heading.text, heading.secondaryText, {}}}; + list.entries = {Entry{heading.text, heading.secondaryText, {}, {}, {}}}; list.style = heading.style; list.secondaryStyle = heading.secondaryStyle; list.useSecondaryStyle = heading.useSecondaryStyle; From dcf7ca37976a901191f2e10c16abfa72ddacddde Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:13:52 +0000 Subject: [PATCH 2/7] Make a divider's ends the same thing as its middle The middle of a divider was a list of pieces -- ornaments, a word, an image, each sized where it sits -- and an end was a single shape out of a shorter library. So a diamond could break a rule but a heart could not cap one, and MMXXVI could sit in the middle of a rule but never at the end of it, for no reason either half could explain. Both ends are now the same `QVector` the centre has always been, and the shape library has one role for anything drawn once at its own size instead of one for caps and another for centrepieces. An arm keeps its own role: it is the rule itself, tiled along a span, which is a different job. A mirrored end draws the list in reverse with each ornament flipped, so a lopsided end still comes out symmetric; a word and a picture are deliberately left unflipped, because mirrored type is a mistake rather than a design. An end written as a single shape -- every document there is -- loads as the one-piece stack that draws the same divider, under its own key so a reader of either shape cannot meet a key that is a string in one document and an array in the next. In the designer the three stacks are three tabs over one table, since they hold the same kind of piece and tabs are what say so; the right-hand tab goes away while the ends are mirrored. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SctZkVkj2RxyH7oJUvH6wQ --- data/locale/en-US.ini | 11 +- src/model/CreditsModel.cpp | 96 ++++++--- src/model/CreditsModel.hpp | 21 +- src/model/DividerArt.cpp | 52 ++--- src/model/DividerArt.hpp | 14 +- src/render/StripRenderer.cpp | 192 +++++++++-------- src/ui/SectionEditor.cpp | 395 ++++++++++++++++++++--------------- src/ui/SectionEditor.hpp | 52 +++-- tests/CMakeLists.txt | 1 + tests/suites/Dividers.cpp | 203 ++++++++++++++++++ tests/suites/Persistence.cpp | 48 ++++- 11 files changed, 738 insertions(+), 347 deletions(-) create mode 100644 tests/suites/Dividers.cpp diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 955855d..61f6a5d 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -143,12 +143,8 @@ Designer.SubtitleOrder="Stacking Order" Designer.SubtitleOrder.Tip="Which of the two lines is drawn on top. The fields you type into keep their meaning either way." Designer.SubtitleOrder.TitleFirst="Title above subtitle" Designer.SubtitleOrder.SubtitleFirst="Subtitle above title" -Designer.DividerCap="End Shape" -Designer.DividerCapSvg="End Artwork" Designer.DividerMirrorEnds="Draw both ends the same way" Designer.DividerMirrorEnds.Tip="The right-hand end is the left one flipped over, which is what keeps the two ends of a rule from drifting apart as you work on it. Turn it off to set them separately — for an arrow that flies one way." -Designer.DividerEndCap="Right-Hand End" -Designer.DividerEndCapSvg="Right-Hand End Artwork" Designer.DividerArm="Rule" Designer.DividerArmSvg="Rule Artwork" Designer.DividerThickness="Thickness" @@ -256,8 +252,11 @@ Designer.Column.EntryTitle="Title" Designer.Column.Subtitle="Subtitle" Designer.Column.Logo="Logo Path" Designer.Column.Height="Height" -Designer.DividerCentre="Middle Of The Divider" -Designer.DividerCentre.Tip="What sits between the two halves of the rule, in order, left to right. Leave it empty for an unbroken rule. Pieces can be ornaments, a word, or an image — a diamond with a dot either side of it, or MMXXVI between two curls." +Designer.DividerPieces="Parts Of The Divider" +Designer.DividerPieces.Tip="What sits at each end of the rule and in the middle of it, in order, left to right. Every tab holds the same kind of piece — an ornament, a word or an image — so a shape offered at an end is offered in the middle too. Leave a tab empty for an end with nothing on it, or an unbroken rule." +Designer.DividerLeftEnd="Left End" +Designer.DividerRightEnd="Right End" +Designer.DividerCentre="Middle" Designer.AddPiece="Add" Designer.RemovePiece="Remove" Designer.SetPieceFile="Set File..." diff --git a/src/model/CreditsModel.cpp b/src/model/CreditsModel.cpp index acbe7f2..c973731 100644 --- a/src/model/CreditsModel.cpp +++ b/src/model/CreditsModel.cpp @@ -719,10 +719,6 @@ void Section::save(obs_data_t *data) const obs_data_set_string(data, "bridge_row_align", hAlignId(bridgeRowAlign)); obs_data_set_bool(data, "bridge_span_empty", bridgeSpanEmpty); obs_data_set_bool(data, "row_subtitles", rowSubtitles); - obs_data_set_string(data, "divider_cap", dividerShapeId(dividerCap)); - obs_data_set_string(data, "divider_cap_svg", dividerCapSvg.toUtf8().constData()); - obs_data_set_string(data, "divider_end_cap", dividerShapeId(dividerEndCap)); - obs_data_set_string(data, "divider_end_cap_svg", dividerEndCapSvg.toUtf8().constData()); obs_data_set_bool(data, "divider_mirror_ends", dividerMirrorEnds); obs_data_set_string(data, "divider_arm", dividerShapeId(dividerArm)); obs_data_set_string(data, "divider_arm_svg", dividerArmSvg.toUtf8().constData()); @@ -786,12 +782,23 @@ void Section::save(obs_data_t *data) const }, &entries); - saveArray( - data, "divider_centre", dividerCentre.size(), - [](obs_data_t *item, int index, const void *context) { - static_cast *>(context)->at(index).save(item); - }, - ÷rCentre); + const auto savePieces = [data](const char *key, const QVector &pieces) { + saveArray( + data, key, pieces.size(), + [](obs_data_t *item, int index, const void *context) { + static_cast *>(context)->at(index).save(item); + }, + &pieces); + }; + + savePieces("divider_centre", dividerCentre); + /* + * Under keys of their own rather than the "divider_cap"/"divider_end_cap" a single shape was + * written to: those still have to be readable as what they were, and a key that is a string + * in one document and an array in the next is a trap for every reader of either. + */ + savePieces("divider_cap_pieces", dividerCap); + savePieces("divider_end_cap_pieces", dividerEndCap); } void Section::load(obs_data_t *data) @@ -854,10 +861,6 @@ void Section::load(obs_data_t *data) * exception: an arm of None is a divider with no rule in it at all, which is never what a * missing key means, so that one falls back to the plain rule. */ - dividerCap = dividerShapeFromId(obs_data_get_string(data, "divider_cap"), DividerShape::None); - dividerCapSvg = QString::fromUtf8(obs_data_get_string(data, "divider_cap_svg")); - dividerEndCap = dividerShapeFromId(obs_data_get_string(data, "divider_end_cap"), DividerShape::None); - dividerEndCapSvg = QString::fromUtf8(obs_data_get_string(data, "divider_end_cap_svg")); dividerMirrorEnds = obs_data_has_user_value(data, "divider_mirror_ends") ? obs_data_get_bool(data, "divider_mirror_ends") : true; @@ -1002,18 +1005,51 @@ void Section::load(obs_data_t *data) } } - dividerCentre.clear(); - OBSDataArrayAutoRelease centreArray = obs_data_get_array(data, "divider_centre"); - if (centreArray) { - const size_t count = obs_data_array_count(centreArray); - dividerCentre.reserve(static_cast(count)); + const auto loadPieces = [data](const char *key, QVector *into) { + into->clear(); + + OBSDataArrayAutoRelease array = obs_data_get_array(data, key); + if (!array) + return false; + + const size_t count = obs_data_array_count(array); + into->reserve(static_cast(count)); for (size_t i = 0; i < count; ++i) { - OBSDataAutoRelease item = obs_data_array_item(centreArray, i); + OBSDataAutoRelease item = obs_data_array_item(array, i); DividerPiece piece; piece.load(item); - dividerCentre.append(piece); + into->append(piece); } - } + return true; + }; + + loadPieces("divider_centre", ÷rCentre); + + /* + * An end written as a single shape -- every document from before an end was a stack -- is + * read back as the one-piece stack it is. None is the fallback there for the reason it is + * everywhere else in a divider, and a `None` end is simply an end with nothing on it, so it + * migrates to an empty list rather than to a piece that draws nothing. + */ + const auto loadEnd = [&](const char *key, const char *legacyShape, const char *legacyFile, + QVector *into) { + if (loadPieces(key, into)) + return; + + const DividerShape shape = + dividerShapeFromId(obs_data_get_string(data, legacyShape), DividerShape::None); + if (dividerShapeIsEmpty(shape)) + return; + + DividerPiece piece; + piece.kind = DividerPiece::Kind::Ornament; + piece.shape = shape; + piece.svgPath = QString::fromUtf8(obs_data_get_string(data, legacyFile)); + into->append(piece); + }; + + loadEnd("divider_cap_pieces", "divider_cap", "divider_cap_svg", ÷rCap); + loadEnd("divider_end_cap_pieces", "divider_end_cap", "divider_end_cap_svg", ÷rEndCap); } Section Section::makeDefault(SectionType type) @@ -1156,7 +1192,7 @@ Section Section::makeDefault(SectionType type) * single ornament, because the first thing anyone does with a new divider is take a * piece out or put one in, and starting from three shows that the centre is a list. */ - section.dividerCap = DividerShape::Arrow; + section.dividerCap = {DividerPiece{DividerPiece::Kind::Ornament, DividerShape::Arrow, {}, 1.0, {}, {}}}; section.dividerArm = DividerShape::Rule; section.dividerThickness = 5.0; section.dividerCentre.append( @@ -1511,12 +1547,16 @@ QVector Document::usedFonts() const * than assumed: reporting a font for a roll whose every divider is pure artwork * would send the user hunting for a substitution that never happened. */ + const auto holdsWord = [](const QVector &pieces) { + return std::any_of(pieces.cbegin(), pieces.cend(), [](const DividerPiece &piece) { + return piece.kind == DividerPiece::Kind::Text && !piece.text.isEmpty(); + }); + }; + + /* Any of the three stacks can hold a word, so all three are asked. */ const bool dividerText = section.type == SectionType::SectionDivider && - std::any_of(section.dividerCentre.cbegin(), section.dividerCentre.cend(), - [](const DividerPiece &piece) { - return piece.kind == DividerPiece::Kind::Text && - !piece.text.isEmpty(); - }); + (holdsWord(section.dividerCentre) || holdsWord(section.dividerCap) || + holdsWord(section.dividerEndCap)); if (!sectionUsesText(section.type) && !dividerText) continue; diff --git a/src/model/CreditsModel.hpp b/src/model/CreditsModel.hpp index 286a0c9..6fbeade 100644 --- a/src/model/CreditsModel.hpp +++ b/src/model/CreditsModel.hpp @@ -655,15 +655,26 @@ struct Section { * a divider whose text is white while its rule carries the title's gold sweep is precisely * what the override is for. */ - DividerShape dividerCap = DividerShape::None; - /* Custom caps only: absolute path to the artwork drawn at the left-hand end. */ - QString dividerCapSvg; + /* + * The left-hand end, outermost piece first. + * + * A list of the same `DividerPiece` the centre holds rather than a single shape, because an + * end is the same kind of thing a middle is: something drawn once at its own size, and just + * as often a compound -- a diamond outside an arrowhead, a year set against the rule. Making + * them the same list means a shape offered in one place is offered in the other, a piece can + * be resized where it sits, and a word or a mark can cap a rule exactly as it can break one. + * + * Empty is an end with nothing on it, which is the ordinary case for a plain rule. + */ + QVector dividerCap; /* * The right-hand end, used only when `dividerMirrorEnds` is off. Kept whether or not it is * in use, so mirroring can be switched off and back on without losing what was set. + * + * Written in the same order as the left-hand one -- outermost piece first -- and drawn + * flipped, so an end reads the same way whichever list it came from. */ - DividerShape dividerEndCap = DividerShape::None; - QString dividerEndCapSvg; + QVector dividerEndCap; /* * Draw the right-hand end as the left one flipped. On by default, and true of every * ornamental rule that is not an arrow pointing somewhere: it is what stops the two ends of diff --git a/src/model/DividerArt.cpp b/src/model/DividerArt.cpp index 869a4bb..780e9df 100644 --- a/src/model/DividerArt.cpp +++ b/src/model/DividerArt.cpp @@ -25,9 +25,8 @@ namespace closingtime { namespace { -constexpr unsigned kCap = DividerRoleCap; +constexpr unsigned kPiece = DividerRolePiece; constexpr unsigned kArm = DividerRoleArm; -constexpr unsigned kOrn = DividerRoleOrnament; /* * The built-in divider shapes, in the order the designer's pickers list them. @@ -48,7 +47,7 @@ constexpr unsigned kOrn = DividerRoleOrnament; * thin a stroke on one axis. */ const DividerShapeInfo kDividerShapes[] = { - {DividerShape::None, "none", "None", "", 0.0, 0.0, DividerStretch::Spread, kCap | kOrn}, + {DividerShape::None, "none", "None", "", 0.0, 0.0, DividerStretch::Spread, kPiece}, /* --- Ends ------------------------------------------------------------------------- */ @@ -58,14 +57,14 @@ const DividerShapeInfo kDividerShapes[] = { */ {DividerShape::Arrow, "arrow", "Arrowhead", R"()", 1.15, 4.5, DividerStretch::Spread, - kCap}, + kPiece}, {DividerShape::Spear, "spear", "Spearpoint", R"()", - 2.0, 3.0, DividerStretch::Spread, kCap}, + 2.0, 3.0, DividerStretch::Spread, kPiece}, {DividerShape::Teardrop, "teardrop", "Teardrop", R"()", 1.7, 3.0, - DividerStretch::Spread, kCap}, + DividerStretch::Spread, kPiece}, /* Two curls off one stem, so the terminal is symmetric about the rule it caps. */ {DividerShape::ScrollEnd, "scroll_end", "Scroll End", @@ -74,7 +73,7 @@ const DividerShapeInfo kDividerShapes[] = { R"()" R"()" R"()", - 1.8, 4.0, DividerStretch::Spread, kCap}, + 1.8, 4.0, DividerStretch::Spread, kPiece}, {DividerShape::DecoStep, "deco_step", "Deco Step", R"()" @@ -82,11 +81,11 @@ const DividerShapeInfo kDividerShapes[] = { R"()" R"()" R"()", - 1.0, 3.0, DividerStretch::Spread, kCap}, + 1.0, 3.0, DividerStretch::Spread, kPiece}, {DividerShape::Chevron, "chevron", "Chevron", R"()", 0.62, 3.5, DividerStretch::Spread, - kCap}, + kPiece}, /* --- Rules ------------------------------------------------------------------------ */ @@ -129,30 +128,30 @@ const DividerShapeInfo kDividerShapes[] = { /* --- Centrepieces ----------------------------------------------------------------- */ {DividerShape::Diamond, "diamond", "Diamond", R"()", 2.0, - 3.5, DividerStretch::Spread, kCap | kOrn}, + 3.5, DividerStretch::Spread, kPiece}, {DividerShape::NestedDiamond, "nested_diamond", "Nested Diamond", R"()" R"()", - 2.6, 4.5, DividerStretch::Spread, kOrn}, + 2.6, 4.5, DividerStretch::Spread, kPiece}, {DividerShape::Sparkle, "sparkle", "Four-Point Star", R"()", - 1.3, 4.5, DividerStretch::Spread, kCap | kOrn}, + 1.3, 4.5, DividerStretch::Spread, kPiece}, {DividerShape::Star, "star", "Five-Point Star", R"()", - 1.0, 5.0, DividerStretch::Spread, kOrn}, + 1.0, 5.0, DividerStretch::Spread, kPiece}, {DividerShape::Dot, "dot", "Dot", R"()", 1.0, 2.5, - DividerStretch::Spread, kCap | kOrn}, + DividerStretch::Spread, kPiece}, {DividerShape::Heart, "heart", "Heart", R"()", - 1.1, 3.5, DividerStretch::Spread, kOrn}, + 1.1, 3.5, DividerStretch::Spread, kPiece}, /* An ogee hooked under at each end -- the flourish, without the diamond it usually frames. */ {DividerShape::Scroll, "scroll", "Scroll", @@ -161,7 +160,7 @@ const DividerShapeInfo kDividerShapes[] = { R"( C1.7 0.2 1.82 0.44 2.0 0.7 C2.14 0.9 2.5 0.86 2.5 0.62 C2.5 0.44 2.24 0.4 2.14 0.54"/>)" R"()" R"()", - 2.6, 3.5, DividerStretch::Spread, kOrn}, + 2.6, 3.5, DividerStretch::Spread, kPiece}, /* * Two mirrored curls with nothing between them, which is the point: whatever belongs in @@ -173,7 +172,7 @@ const DividerShapeInfo kDividerShapes[] = { R"()" R"()" R"()", - 3.0, 4.5, DividerStretch::Spread, kOrn}, + 3.0, 4.5, DividerStretch::Spread, kPiece}, /* * The two lozenges overlap by a little over a tenth of their width -- enough to read as @@ -185,7 +184,7 @@ const DividerShapeInfo kDividerShapes[] = { R"()" R"()" R"()", - 3.2, 4.5, DividerStretch::Spread, kOrn}, + 3.2, 4.5, DividerStretch::Spread, kPiece}, /* * Custom art is measured from the file's own viewBox rather than from `aspect`, and it @@ -195,7 +194,7 @@ const DividerShapeInfo kDividerShapes[] = { * piece's own size multiplier is what corrects it. In an arm the number is not read at all: * an arm is the rule, and is drawn at exactly the thickness. */ - {DividerShape::Custom, "custom", "Custom SVG", "", 1.0, 4.0, DividerStretch::Spread, kCap | kArm | kOrn}, + {DividerShape::Custom, "custom", "Custom SVG", "", 1.0, 4.0, DividerStretch::Spread, kPiece | kArm}, }; } // namespace @@ -242,19 +241,10 @@ const QVector ÷rShapesForRole(DividerRole role) return result; }; - static const QVector caps = build(DividerRoleCap); + static const QVector pieces = build(DividerRolePiece); static const QVector arms = build(DividerRoleArm); - static const QVector ornaments = build(DividerRoleOrnament); - - switch (role) { - case DividerRoleArm: - return arms; - case DividerRoleOrnament: - return ornaments; - case DividerRoleCap: - break; - } - return caps; + + return role == DividerRoleArm ? arms : pieces; } bool dividerShapeHasRole(DividerShape shape, DividerRole role) diff --git a/src/model/DividerArt.hpp b/src/model/DividerArt.hpp index 0076d15..4dba7a9 100644 --- a/src/model/DividerArt.hpp +++ b/src/model/DividerArt.hpp @@ -34,7 +34,9 @@ namespace closingtime { * * Which slots a shape may be picked for is `roles`, not its name: a diamond is a perfectly good * end cap and a perfectly good centrepiece, and saying so once is better than a second Diamond - * in a second table that has to be kept looking like the first. + * in a second table that has to be kept looking like the first. Every shape that is drawn once at + * its own size now carries the one role that says so, so anything offered at an end is offered in + * the middle and the other way about. * * Persisted by string id (dividerShapeId), never by ordinal, so the enum may be reordered. */ @@ -77,11 +79,17 @@ enum class DividerShape { /* * Which slots a shape is offered for. A shape may serve more than one -- see the note on * `roles` above. + * + * There are two slots rather than three because an end and a middle are the same slot: both are a + * stack of pieces, so a shape that suits one suits the other, and keeping them apart only meant + * two lists that had to be argued about shape by shape. An arm is the exception and stays its own + * role -- it is the rule itself, tiled along a span, which is a different job from being drawn + * once at its own size. */ enum DividerRole : unsigned { - DividerRoleCap = 1u << 0, + /* An end cap or a centrepiece: anything drawn once, at its own size, in a stack. */ + DividerRolePiece = 1u << 0, DividerRoleArm = 1u << 1, - DividerRoleOrnament = 1u << 2, }; /* diff --git a/src/render/StripRenderer.cpp b/src/render/StripRenderer.cpp index c8d27f3..8e97ff7 100644 --- a/src/render/StripRenderer.cpp +++ b/src/render/StripRenderer.cpp @@ -1179,7 +1179,7 @@ struct DividerLayout { qreal height = 0.0; }; -/* One centre piece, measured but not yet placed. */ +/* One piece of a divider -- an end or a centrepiece -- measured but not yet placed. */ struct MeasuredPiece { const DividerPiece *piece; QSizeF size; @@ -1187,29 +1187,30 @@ struct MeasuredPiece { LogoArt art; }; +/* A measured stack of them: what one end or the middle of a divider comes to. */ +struct MeasuredStack { + QVector pieces; + qreal width = 0.0; + qreal height = 0.0; + + bool isEmpty() const { return pieces.isEmpty(); } +}; + /* - * Composes a divider across `width`, starting at `x`, with its content beginning at `top`. + * Measures a run of divider pieces laid side by side, `pieceGap` apart. * - * Nothing is painted here: the whole figure is measured and placed, and the caller paints from - * the result. That is what keeps the measure pass and the render pass in agreement -- they are - * the same call, and a divider whose height is reported as one thing and drawn as another would - * shift every section under it by the difference. + * One helper for all three stacks rather than one for the middle and a separate path for the two + * ends, because an end *is* a stack: that is the whole of what makes a shape offered in one place + * offered in the other, and a word or a mark able to cap a rule as well as break one. */ -DividerLayout layoutDivider(const Section §ion, const TextStyle &style, const LogoSource &logos, - DividerArtCache *dividers, qreal x, qreal top, qreal width) +MeasuredStack measureDividerStack(const QVector &pieces, const TextStyle &style, + const LogoSource &logos, DividerArtCache *dividers, qreal thickness, + qreal pieceGap, qreal available) { - DividerLayout layout; - - const qreal thickness = section.dividerThickness; - if (!dividers || thickness <= 0.0 || width <= 0.0) - return layout; - - /* --- Measure the centre stack ---------------------------------------------------- */ - - QVector centre; - centre.reserve(section.dividerCentre.size()); + MeasuredStack stack; + stack.pieces.reserve(pieces.size()); - for (const DividerPiece &piece : section.dividerCentre) { + for (const DividerPiece &piece : pieces) { MeasuredPiece measured{&piece, QSizeF(), LogoArt()}; switch (piece.kind) { @@ -1222,9 +1223,9 @@ DividerLayout layoutDivider(const Section §ion, const TextStyle &style, cons break; /* * Measured at its natural width and then laid out into exactly that, so the - * word occupies the room it needs and never wraps inside a divider. A centre - * piece has no column to wrap into -- the arms are what the space either - * side of it is for. + * word occupies the room it needs and never wraps inside a divider. A divider + * piece has no column to wrap into -- the arms are what the space around it + * is for. */ const qreal textWidth = naturalTextWidth(piece.text, style); const qreal textHeight = layoutText(nullptr, piece.text, style, 0, 0, textWidth); @@ -1234,7 +1235,7 @@ DividerLayout layoutDivider(const Section §ion, const TextStyle &style, cons case DividerPiece::Kind::Logo: { measured.art = logos.resolve(piece.logo); - measured.size = QSizeF(logoDrawSize(measured.art.poster, piece.logo, qRound(width))); + measured.size = QSizeF(logoDrawSize(measured.art.poster, piece.logo, qRound(available))); break; } } @@ -1242,37 +1243,97 @@ DividerLayout layoutDivider(const Section §ion, const TextStyle &style, cons /* * A piece that measures to nothing -- an empty word, a logo that would not decode, a * shape whose file is missing -- is dropped along with the gap that would have sat - * beside it, rather than left as a hole in the middle of the rule. + * beside it, rather than left as a hole in the rule. */ if (measured.size.width() > 0.0 && measured.size.height() > 0.0) - centre.append(measured); + stack.pieces.append(measured); } - qreal centreWidth = 0.0; - qreal centreHeight = 0.0; - for (int i = 0; i < centre.size(); ++i) { - centreWidth += centre.at(i).size.width(); + for (int i = 0; i < stack.pieces.size(); ++i) { + stack.width += stack.pieces.at(i).size.width(); if (i > 0) - centreWidth += section.dividerPieceGap; - centreHeight = std::max(centreHeight, centre.at(i).size.height()); + stack.width += pieceGap; + stack.height = std::max(stack.height, stack.pieces.at(i).size.height()); + } + + return stack; +} + +/* + * Places a measured stack, left to right from `left`, centred on `midY`. + * + * `mirrored` is what an end at the right-hand side of the divider is drawn with: the pieces run in + * reverse, so the figure is a mirror image of the same list, and each piece's *artwork* is flipped + * along x -- the caps in the shape library are authored pointing outward along -x, so an arrowhead + * at the right-hand end has to be. A word and a picture are deliberately left unflipped: mirrored + * type is not a design, it is a mistake, and neither is authored pointing anywhere. + */ +void placeDividerStack(DividerLayout *layout, const MeasuredStack &stack, qreal left, qreal midY, qreal pieceGap, + bool mirrored) +{ + qreal cursor = left; + + for (int i = 0; i < stack.pieces.size(); ++i) { + const MeasuredPiece &measured = stack.pieces.at(mirrored ? stack.pieces.size() - 1 - i : i); + const DividerPiece &piece = *measured.piece; + const QRectF box(cursor, midY - measured.size.height() / 2.0, measured.size.width(), + measured.size.height()); + + switch (piece.kind) { + case DividerPiece::Kind::Ornament: + layout->art.append(DividerArtPlacement{box, piece.shape, piece.svgPath, mirrored}); + break; + + case DividerPiece::Kind::Text: + layout->texts.append(DividerLayout::TextPiece{piece.text, box}); + break; + + case DividerPiece::Kind::Logo: + layout->logos.append(DividerLayout::LogoPiece{measured.art, &piece.logo, box.toRect()}); + break; + } + + cursor += measured.size.width() + pieceGap; } +} + +/* + * Composes a divider across `width`, starting at `x`, with its content beginning at `top`. + * + * Nothing is painted here: the whole figure is measured and placed, and the caller paints from + * the result. That is what keeps the measure pass and the render pass in agreement -- they are + * the same call, and a divider whose height is reported as one thing and drawn as another would + * shift every section under it by the difference. + */ +DividerLayout layoutDivider(const Section §ion, const TextStyle &style, const LogoSource &logos, + DividerArtCache *dividers, qreal x, qreal top, qreal width) +{ + DividerLayout layout; - /* --- Measure the ends ------------------------------------------------------------ */ + const qreal thickness = section.dividerThickness; + if (!dividers || thickness <= 0.0 || width <= 0.0) + return layout; + /* --- Measure the three stacks ---------------------------------------------------- */ + + const qreal pieceGap = section.dividerPieceGap; + const auto measure = [&](const QVector &pieces) { + return measureDividerStack(pieces, style, logos, dividers, thickness, pieceGap, width); + }; + + const MeasuredStack centre = measure(section.dividerCentre); + const MeasuredStack leftEnd = measure(section.dividerCap); /* - * Both caps are authored pointing outward along -x, so the right-hand one is the same - * artwork drawn mirrored whether it is the left cap repeated or a second shape of its own. + * Mirroring says the two ends are the same list, not that the right-hand one is drawn + * unflipped: an end is always drawn as a mirror image of the way it is written, whichever + * list it came from, so the divider cannot come out pointing the same way at both ends. */ - const DividerShape rightShape = section.dividerMirrorEnds ? section.dividerCap : section.dividerEndCap; - const QString &rightFile = section.dividerMirrorEnds ? section.dividerCapSvg : section.dividerEndCapSvg; - - const QSizeF leftCap = dividerShapeSize(section.dividerCap, section.dividerCapSvg, dividers, thickness); - const QSizeF rightCap = dividerShapeSize(rightShape, rightFile, dividers, thickness); + const MeasuredStack rightEnd = section.dividerMirrorEnds ? leftEnd : measure(section.dividerEndCap); const int rules = std::clamp(section.dividerRules, 1, 16); const qreal stackHeight = rules * thickness + (rules - 1) * section.dividerRuleGap; - layout.height = std::max({stackHeight, leftCap.height(), rightCap.height(), centreHeight}); + layout.height = std::max({stackHeight, leftEnd.height, rightEnd.height, centre.height}); if (layout.height <= 0.0) return layout; @@ -1281,24 +1342,16 @@ DividerLayout layoutDivider(const Section §ion, const TextStyle &style, cons /* --- Place the ends -------------------------------------------------------------- */ /* - * The caps sit at the divider's full extent and on its midline, and are drawn once however + * The ends sit at the divider's full extent and on its midline, and are drawn once however * many rules run between them: three lines meeting one arrowhead is the figure the deco * rules draw, where three arrowheads stacked on top of each other is not. */ - const auto placeCap = [&](DividerShape shape, const QString &file, const QSizeF &size, qreal left, - bool mirrored) { - if (size.isEmpty()) - return; - layout.art.append(DividerArtPlacement{ - QRectF(left, midY - size.height() / 2.0, size.width(), size.height()), shape, file, mirrored}); - }; - - placeCap(section.dividerCap, section.dividerCapSvg, leftCap, x, false); - placeCap(rightShape, rightFile, rightCap, x + width - rightCap.width(), true); + placeDividerStack(&layout, leftEnd, x, midY, pieceGap, false); + placeDividerStack(&layout, rightEnd, x + width - rightEnd.width, midY, pieceGap, true); - /* Nothing to keep clear of at an end with no cap on it. */ - const qreal armLeft = x + leftCap.width() + (leftCap.width() > 0.0 ? section.dividerGap : 0.0); - const qreal armRight = x + width - rightCap.width() - (rightCap.width() > 0.0 ? section.dividerGap : 0.0); + /* Nothing to keep clear of at an end with nothing on it. */ + const qreal armLeft = x + leftEnd.width + (leftEnd.width > 0.0 ? section.dividerGap : 0.0); + const qreal armRight = x + width - rightEnd.width - (rightEnd.width > 0.0 ? section.dividerGap : 0.0); /* --- Place the centre ------------------------------------------------------------ */ @@ -1306,34 +1359,9 @@ DividerLayout layoutDivider(const Section §ion, const TextStyle &style, cons * Centred on the section's own box rather than between the two arms, so a divider whose * ends differ still has its ornament on the middle of the line the eye follows. */ - const qreal centreLeft = x + (width - centreWidth) / 2.0; + const qreal centreLeft = x + (width - centre.width) / 2.0; - qreal cursor = centreLeft; - for (const MeasuredPiece &measured : centre) { - const DividerPiece &piece = *measured.piece; - const qreal pieceTop = midY - measured.size.height() / 2.0; - - switch (piece.kind) { - case DividerPiece::Kind::Ornament: - layout.art.append(DividerArtPlacement{QRectF(cursor, pieceTop, measured.size.width(), - measured.size.height()), - piece.shape, piece.svgPath, false}); - break; - - case DividerPiece::Kind::Text: - layout.texts.append(DividerLayout::TextPiece{ - piece.text, QRectF(cursor, pieceTop, measured.size.width(), measured.size.height())}); - break; - - case DividerPiece::Kind::Logo: - layout.logos.append(DividerLayout::LogoPiece{ - measured.art, &piece.logo, - QRectF(cursor, pieceTop, measured.size.width(), measured.size.height()).toRect()}); - break; - } - - cursor += measured.size.width() + section.dividerPieceGap; - } + placeDividerStack(&layout, centre, centreLeft, midY, pieceGap, false); /* --- Place the arms -------------------------------------------------------------- */ @@ -1384,7 +1412,7 @@ DividerLayout layoutDivider(const Section §ion, const TextStyle &style, cons placeArm(left, right, false); } else { placeArm(left, centreLeft - section.dividerGap, false); - placeArm(centreLeft + centreWidth + section.dividerGap, right, true); + placeArm(centreLeft + centre.width + section.dividerGap, right, true); } } diff --git a/src/ui/SectionEditor.cpp b/src/ui/SectionEditor.cpp index cd35746..b6fc4c5 100644 --- a/src/ui/SectionEditor.cpp +++ b/src/ui/SectionEditor.cpp @@ -36,12 +36,14 @@ with this program. If not, see #include #include #include +#include #include #include #include #include #include +#include #include "render/AnimatedLogo.hpp" #include "ui/CsvImportDialog.hpp" @@ -50,6 +52,25 @@ with this program. If not, see namespace closingtime { +const QVector ÷rPieces(const Section §ion, PieceSlot slot) +{ + switch (slot) { + case PieceSlot::LeftEnd: + return section.dividerCap; + case PieceSlot::RightEnd: + return section.dividerEndCap; + case PieceSlot::Centre: + break; + } + return section.dividerCentre; +} + +QVector ÷rPieces(Section §ion, PieceSlot slot) +{ + /* The const one is the definition; this is the same walk with the constness taken back off. */ + return const_cast &>(dividerPieces(std::as_const(section), slot)); +} + namespace { QString moduleText(const char *key) @@ -164,21 +185,21 @@ QString dividerPieceKindText(DividerPiece::Kind kind) } /* Columns of the divider's centre-piece table. */ -enum CentreColumn { +enum PieceColumn { /* Ornament, text or logo. */ - CentreKind = 0, + PieceKind = 0, /* Ornament pieces only: which shape from the library. */ - CentreShape, + PieceShape, /* The word, for a text piece; the file, for a logo or a custom ornament. */ - CentreValue, + PieceValue, /* Ornament pieces only: a multiplier on the size its shape asks for. */ - CentreSize, - CentreColumnCount, + PieceSize, + PieceColumnCount, }; /* Width of the centre table's two narrow columns, neither of which holds a long word. */ -constexpr int kCentreKindColumnWidth = 110; -constexpr int kCentreSizeColumnWidth = 70; +constexpr int kPieceKindColumnWidth = 110; +constexpr int kPieceSizeColumnWidth = 70; void addAlignmentOptions(QComboBox *box) { @@ -780,14 +801,10 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) connect(browse, &QToolButton::clicked, this, [this, target] { browseForDividerSvg(target); }); }; - addShapeRow(÷rCap, ÷rCapSvgPath, DividerRoleCap, "Designer.DividerCap", "Designer.DividerCapSvg"); - dividerMirrorEnds = new QCheckBox(moduleText("Designer.DividerMirrorEnds"), this); dividerMirrorEnds->setToolTip(moduleText("Designer.DividerMirrorEnds.Tip")); form->addRow(QString(), dividerMirrorEnds); - addShapeRow(÷rEndCap, ÷rEndCapSvgPath, DividerRoleCap, "Designer.DividerEndCap", - "Designer.DividerEndCapSvg"); addShapeRow(÷rArm, ÷rArmSvgPath, DividerRoleArm, "Designer.DividerArm", "Designer.DividerArmSvg"); dividerThickness = new QSpinBox(this); @@ -978,63 +995,87 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) outer->addWidget(entriesGroup, 1); /* - * The divider's centre stack. A table of its own rather than a second mode of the entry - * table above: a centre piece is a kind, a shape, a word and a size, which shares no column - * with a credit, and the two are never shown at the same time anyway. + * The divider's three piece stacks: its two ends and its middle. + * + * One table apiece, in tabs, rather than one table and a selector -- an end and a middle + * hold exactly the same kind of piece, and tabs are what say so while keeping three lists + * out of one pane. A table of its own rather than a second mode of the entry table above: + * a piece is a kind, a shape, a word and a size, which shares no column with a credit. */ - centreGroup = new QGroupBox(moduleText("Designer.DividerCentre"), this); - centreGroup->setToolTip(moduleText("Designer.DividerCentre.Tip")); - auto *centreLayout = new QVBoxLayout(centreGroup); - - centreTable = new QTableWidget(centreGroup); - centreTable->setSelectionBehavior(QAbstractItemView::SelectRows); - centreTable->verticalHeader()->setVisible(false); - centreTable->setColumnCount(CentreColumnCount); - centreTable->setHorizontalHeaderLabels( - {moduleText("Designer.Column.PieceKind"), moduleText("Designer.Column.PieceShape"), - moduleText("Designer.Column.PieceValue"), moduleText("Designer.Column.PieceSize")}); - centreTable->setMinimumHeight(kCentreTableMinimumHeight); + dividerPiecesGroup = new QGroupBox(moduleText("Designer.DividerPieces"), this); + dividerPiecesGroup->setToolTip(moduleText("Designer.DividerPieces.Tip")); + auto *dividerPiecesLayout = new QVBoxLayout(dividerPiecesGroup); - /* - * The value column is the one thing in a row long enough to need reading -- a word, or a - * path to a file -- so it takes the slack and the other three keep only what they need. - */ - QHeaderView *centreHeader = centreTable->horizontalHeader(); - centreHeader->setStretchLastSection(false); - centreHeader->setSectionResizeMode(QHeaderView::Interactive); - centreHeader->setSectionResizeMode(CentreValue, QHeaderView::Stretch); - centreTable->setColumnWidth(CentreKind, kCentreKindColumnWidth); - centreTable->setColumnWidth(CentreSize, kCentreSizeColumnWidth); - - centreLayout->addWidget(centreTable); - - auto *centreButtons = new QHBoxLayout(); - const auto addCentreButton = [&](QToolButton *button, auto slot) { - centreButtons->addWidget(button); - connect(button, &QToolButton::clicked, this, slot); - return button; - }; + pieceTabs = new QTabWidget(dividerPiecesGroup); + dividerPiecesLayout->addWidget(pieceTabs); - addCentreButton(makeGlyphButton(centreGroup, QStringLiteral("+"), moduleText("Designer.AddPiece")), - &SectionEditor::addCentrePiece); - addCentreButton(makeGlyphButton(centreGroup, QStringLiteral("−"), moduleText("Designer.RemovePiece")), - &SectionEditor::removeSelectedCentrePieces); - addCentreButton(makeArrowButton(centreGroup, Qt::UpArrow, moduleText("Designer.MoveUp")), - [this] { moveSelectedCentrePiece(-1); }); - addCentreButton(makeArrowButton(centreGroup, Qt::DownArrow, moduleText("Designer.MoveDown")), - [this] { moveSelectedCentrePiece(1); }); - /* - * One button for both kinds of file a piece can carry, because which one it opens follows - * from the row it is pointed at: a logo piece wants an image, a custom ornament an SVG. - */ - centreFileButton = addCentreButton(makeLabelledButton(centreGroup, moduleText("Designer.SetPieceFile")), - &SectionEditor::browseForCentreFile); - centreButtons->addStretch(); + static const char *const kSlotTitles[kPieceSlotCount] = {"Designer.DividerLeftEnd", "Designer.DividerCentre", + "Designer.DividerRightEnd"}; + + for (int index = 0; index < kPieceSlotCount; ++index) { + const auto slot = static_cast(index); + + auto *page = new QWidget(pieceTabs); + auto *pageLayout = new QVBoxLayout(page); + pageLayout->setContentsMargins(0, 6, 0, 0); + + auto *table = new QTableWidget(page); + pieceTables[index] = table; + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->verticalHeader()->setVisible(false); + table->setColumnCount(PieceColumnCount); + table->setHorizontalHeaderLabels( + {moduleText("Designer.Column.PieceKind"), moduleText("Designer.Column.PieceShape"), + moduleText("Designer.Column.PieceValue"), moduleText("Designer.Column.PieceSize")}); + table->setMinimumHeight(kCentreTableMinimumHeight); + + /* + * The value column is the one thing in a row long enough to need reading -- a word, + * or a path to a file -- so it takes the slack and the other three keep only what + * they need. + */ + QHeaderView *header = table->horizontalHeader(); + header->setStretchLastSection(false); + header->setSectionResizeMode(QHeaderView::Interactive); + header->setSectionResizeMode(PieceValue, QHeaderView::Stretch); + table->setColumnWidth(PieceKind, kPieceKindColumnWidth); + table->setColumnWidth(PieceSize, kPieceSizeColumnWidth); + + pageLayout->addWidget(table); + + auto *buttonRow = new QHBoxLayout(); + const auto addPieceButton = [&](QToolButton *button, auto slotFn) { + buttonRow->addWidget(button); + connect(button, &QToolButton::clicked, this, slotFn); + return button; + }; + + addPieceButton(makeGlyphButton(page, QStringLiteral("+"), moduleText("Designer.AddPiece")), + [this, slot] { addPiece(slot); }); + addPieceButton(makeGlyphButton(page, QStringLiteral("−"), moduleText("Designer.RemovePiece")), + [this, slot] { removeSelectedPieces(slot); }); + addPieceButton(makeArrowButton(page, Qt::UpArrow, moduleText("Designer.MoveUp")), + [this, slot] { movePiece(slot, -1); }); + addPieceButton(makeArrowButton(page, Qt::DownArrow, moduleText("Designer.MoveDown")), + [this, slot] { movePiece(slot, 1); }); + /* + * One button for both kinds of file a piece can carry, because which one it opens + * follows from the row it is pointed at: a logo piece wants an image, a custom + * ornament an SVG. + */ + pieceFileButtons[index] = + addPieceButton(makeLabelledButton(page, moduleText("Designer.SetPieceFile")), + [this, slot] { browseForPieceFile(slot); }); + buttonRow->addStretch(); + + pageLayout->addLayout(buttonRow); + pieceTabs->addTab(page, moduleText(kSlotTitles[index])); - centreLayout->addLayout(centreButtons); - outer->addWidget(centreGroup, 1); + connect(table, &QTableWidget::itemChanged, this, [this] { emitChanged(); }); + } + + outer->addWidget(dividerPiecesGroup, 1); - connect(centreTable, &QTableWidget::itemChanged, this, [this] { emitChanged(); }); /* * Whatever height is left over when the editor is shorter than the pane it sits in. A @@ -1094,8 +1135,6 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) /* Untinting a custom bridge leaves nothing for the bridge's own colours to reach. */ connect(bridgeTint, &QCheckBox::toggled, this, revisitVisibility); - connect(dividerCapSvgPath, &QLineEdit::textChanged, this, notify); - connect(dividerEndCapSvgPath, &QLineEdit::textChanged, this, notify); connect(dividerArmSvgPath, &QLineEdit::textChanged, this, notify); connect(dividerThickness, &QSpinBox::valueChanged, this, notify); connect(dividerGap, &QSpinBox::valueChanged, this, notify); @@ -1109,7 +1148,7 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) * custom shape reveals its file picker, mirroring hides the second end, and untinting a * custom file leaves nothing for the artwork's own colours to reach. */ - for (QComboBox *box : {dividerCap, dividerEndCap, dividerArm}) + for (QComboBox *box : {dividerArm}) connect(box, &QComboBox::currentIndexChanged, this, revisitVisibility); connect(dividerMirrorEnds, &QCheckBox::toggled, this, revisitVisibility); @@ -1219,11 +1258,7 @@ void SectionEditor::setSection(const Section &source) selectByData(bridgeRowAlign, static_cast(source.bridgeRowAlign)); bridgeSpanEmpty->setChecked(source.bridgeSpanEmpty); rowSubtitles->setChecked(source.rowSubtitles); - selectByData(dividerCap, static_cast(source.dividerCap)); - dividerCapSvgPath->setText(source.dividerCapSvg); dividerMirrorEnds->setChecked(source.dividerMirrorEnds); - selectByData(dividerEndCap, static_cast(source.dividerEndCap)); - dividerEndCapSvgPath->setText(source.dividerEndCapSvg); selectByData(dividerArm, static_cast(source.dividerArm)); dividerArmSvgPath->setText(source.dividerArmSvg); dividerThickness->setValue(qRound(source.dividerThickness)); @@ -1265,7 +1300,8 @@ void SectionEditor::setSection(const Section &source) * pass asks the table whether any piece is drawn from a file. Filling it afterwards would * have the question answered from the section the user just clicked away from. */ - writeCentreToTable(source); + for (int index = 0; index < kPieceSlotCount; ++index) + writePiecesToTable(static_cast(index), source); applyTypeVisibility(source.type); rebuildEntryTable(source.type, source.rowSubtitles); writeEntriesToTable(source); @@ -1313,11 +1349,7 @@ Section SectionEditor::section() const result.bridgeRowAlign = static_cast(bridgeRowAlign->currentData().toInt()); result.bridgeSpanEmpty = bridgeSpanEmpty->isChecked(); result.rowSubtitles = rowSubtitles->isChecked(); - result.dividerCap = static_cast(dividerCap->currentData().toInt()); - result.dividerCapSvg = dividerCapSvgPath->text(); result.dividerMirrorEnds = dividerMirrorEnds->isChecked(); - result.dividerEndCap = static_cast(dividerEndCap->currentData().toInt()); - result.dividerEndCapSvg = dividerEndCapSvgPath->text(); result.dividerArm = static_cast(dividerArm->currentData().toInt()); result.dividerArmSvg = dividerArmSvgPath->text(); result.dividerThickness = dividerThickness->value(); @@ -1353,7 +1385,8 @@ Section SectionEditor::section() const result.rowSecondarySubtitleStylePresetName = rowSecondarySubtitleStyle->presetName(); readEntriesFromTable(&result); - readCentreFromTable(&result); + for (int index = 0; index < kPieceSlotCount; ++index) + readPiecesFromTable(static_cast(index), &result); /* After both tables, which rebuild their logos from cells that carry no playback of their own. */ for (Entry &entry : result.entries) @@ -1464,10 +1497,10 @@ void SectionEditor::applyTypeVisibility(SectionType type) rowSubtitleStyleGroup->setVisible(rowSubtitlesOn); rowSecondarySubtitleStyleGroup->setVisible(rowSubtitlesOn); /* - * The divider's own rows. A slot's file picker follows the shape picked for it, and the - * second end follows the mirror toggle: a divider whose two ends are the same shape has one - * cap setting, and a second one on screen saying nothing invites the reader to look for a - * difference that is not there. + * The divider's own rows. The arm's file picker follows the shape picked for it, and the + * right-hand end's tab follows the mirror toggle: a divider whose two ends are the same list + * has one end to edit, and a second tab holding a list nothing draws from invites the reader + * to look for a difference that is not there. */ const bool divider = type == SectionType::SectionDivider; const auto slotShape = [](QComboBox *box) { @@ -1475,15 +1508,17 @@ void SectionEditor::applyTypeVisibility(SectionType type) }; const bool separateEnds = divider && !dividerMirrorEnds->isChecked(); - const bool capFromFile = divider && dividerShapeUsesFile(slotShape(dividerCap)); - const bool endCapFromFile = separateEnds && dividerShapeUsesFile(slotShape(dividerEndCap)); const bool armFromFile = divider && dividerShapeUsesFile(slotShape(dividerArm)); - form->setRowVisible(dividerCap, divider); - form->setRowVisible(dividerCapSvgPath->parentWidget(), capFromFile); + pieceTabs->setTabVisible(static_cast(PieceSlot::RightEnd), separateEnds); + /* + * A tab going away under the reader takes the selection with it, so the left-hand end -- + * which is what the right one mirrors -- is what they are left looking at. + */ + if (!separateEnds && pieceTabs->currentIndex() == static_cast(PieceSlot::RightEnd)) + pieceTabs->setCurrentIndex(static_cast(PieceSlot::LeftEnd)); + form->setRowVisible(dividerMirrorEnds, divider); - form->setRowVisible(dividerEndCap, separateEnds); - form->setRowVisible(dividerEndCapSvgPath->parentWidget(), endCapFromFile); form->setRowVisible(dividerArm, divider); form->setRowVisible(dividerArmSvgPath->parentWidget(), armFromFile); form->setRowVisible(dividerThickness, divider); @@ -1498,7 +1533,7 @@ void SectionEditor::applyTypeVisibility(SectionType type) * The built-in shapes are drawn white to be tinted, so the flag only means anything once * some slot -- an end, an arm, or a piece of the centre stack -- is pointed at a file. */ - const bool dividerFiles = capFromFile || endCapFromFile || armFromFile || (divider && centreUsesFile()); + const bool dividerFiles = armFromFile || (divider && dividerUsesFile()); form->setRowVisible(dividerTint, dividerFiles); form->setRowVisible(columns, hasColumns); @@ -1548,7 +1583,7 @@ void SectionEditor::applyTypeVisibility(SectionType type) /* Nothing for a file picker to fill in when the entries are lines of text. */ setLogoButton->setVisible(hasEntries && hasLogos); - centreGroup->setVisible(divider); + dividerPiecesGroup->setVisible(divider); /* * Whichever table is on show is the one thing here worth growing, so it takes the leftover @@ -1751,16 +1786,17 @@ void SectionEditor::refreshLogoPlayback() form->setRowVisible(logoAnimatedShadow, animated); } -void SectionEditor::writeCentreToTable(const Section &source) +void SectionEditor::writePiecesToTable(PieceSlot slot, const Section &source) { - const QSignalBlocker blocker(centreTable); + QTableWidget *table = pieceTable(slot); + const QSignalBlocker blocker(table); /* - * Cleared for every other type, and read back only for a divider (see readCentreFromTable), - * so a section switched to something else and back keeps the stack it was built with rather + * Cleared for every other type, and read back only for a divider (see readPiecesFromTable), + * so a section switched to something else and back keeps the stacks it was built with rather * than being handed an empty table's worth of nothing. */ - centreTable->setRowCount(0); + table->setRowCount(0); if (source.type != SectionType::SectionDivider) return; @@ -1768,32 +1804,39 @@ void SectionEditor::writeCentreToTable(const Section &source) * A change to either picker can change which of the other cells in the row mean anything, * and whether the divider draws from a file at all -- which is a row of the form above. */ - const auto onPickerChanged = [this](int row) { - return [this, row] { + const auto onPickerChanged = [this, slot](int row) { + return [this, slot, row] { if (loading) return; - applyCentreRowVisibility(row); + applyPieceRowVisibility(slot, row); applyTypeVisibility(static_cast(typeBox->currentData().toInt())); emitChanged(); }; }; - centreTable->setRowCount(source.dividerCentre.size()); - for (int row = 0; row < source.dividerCentre.size(); ++row) { - const DividerPiece &piece = source.dividerCentre.at(row); + const QVector &pieces = dividerPieces(source, slot); + + table->setRowCount(pieces.size()); + for (int row = 0; row < pieces.size(); ++row) { + const DividerPiece &piece = pieces.at(row); - auto *kindBox = new QComboBox(centreTable); + auto *kindBox = new QComboBox(table); for (DividerPiece::Kind kind : allDividerPieceKinds()) kindBox->addItem(dividerPieceKindText(kind), static_cast(kind)); selectByData(kindBox, static_cast(piece.kind)); - centreTable->setCellWidget(row, CentreKind, kindBox); + table->setCellWidget(row, PieceKind, kindBox); connect(kindBox, &QComboBox::currentIndexChanged, this, onPickerChanged(row)); - auto *shapeBox = new QComboBox(centreTable); - for (DividerShape shape : dividerShapesForRole(DividerRoleOrnament)) + /* + * One list of shapes for all three stacks: an end and a middle are the same slot as + * far as the library is concerned, so whatever is offered in one is offered in the + * other. See DividerRolePiece. + */ + auto *shapeBox = new QComboBox(table); + for (DividerShape shape : dividerShapesForRole(DividerRolePiece)) shapeBox->addItem(dividerShapeText(shape), static_cast(shape)); selectByData(shapeBox, static_cast(piece.shape)); - centreTable->setCellWidget(row, CentreShape, shapeBox); + table->setCellWidget(row, PieceShape, shapeBox); connect(shapeBox, &QComboBox::currentIndexChanged, this, onPickerChanged(row)); /* @@ -1817,30 +1860,32 @@ void SectionEditor::writeCentreToTable(const Section &source) break; } - centreTable->setItem(row, CentreValue, new QTableWidgetItem(value)); - centreTable->setItem(row, CentreSize, new QTableWidgetItem(size)); + table->setItem(row, PieceValue, new QTableWidgetItem(value)); + table->setItem(row, PieceSize, new QTableWidgetItem(size)); - applyCentreRowVisibility(row); + applyPieceRowVisibility(slot, row); } } -void SectionEditor::readCentreFromTable(Section *target) const +void SectionEditor::readPiecesFromTable(PieceSlot slot, Section *target) const { - /* Only a divider's table is ever filled, so only a divider's is ever believed. */ + /* Only a divider's tables are ever filled, so only a divider's are ever believed. */ if (target->type != SectionType::SectionDivider) return; + QTableWidget *table = pieceTable(slot); + QVector pieces; - pieces.reserve(centreTable->rowCount()); + pieces.reserve(table->rowCount()); - for (int row = 0; row < centreTable->rowCount(); ++row) { - const auto *kindBox = qobject_cast(centreTable->cellWidget(row, CentreKind)); - const auto *shapeBox = qobject_cast(centreTable->cellWidget(row, CentreShape)); + for (int row = 0; row < table->rowCount(); ++row) { + const auto *kindBox = qobject_cast(table->cellWidget(row, PieceKind)); + const auto *shapeBox = qobject_cast(table->cellWidget(row, PieceShape)); if (!kindBox || !shapeBox) continue; - const auto cell = [this, row](int column) { - const QTableWidgetItem *item = centreTable->item(row, column); + const auto cell = [table, row](int column) { + const QTableWidgetItem *item = table->item(row, column); return item ? item->text() : QString(); }; @@ -1850,17 +1895,17 @@ void SectionEditor::readCentreFromTable(Section *target) const switch (piece.kind) { case DividerPiece::Kind::Ornament: { - piece.svgPath = cell(CentreValue); - const double scale = cell(CentreSize).toDouble(); + piece.svgPath = cell(PieceValue); + const double scale = cell(PieceSize).toDouble(); piece.scale = scale > 0.0 ? scale : 1.0; break; } case DividerPiece::Kind::Text: - piece.text = cell(CentreValue); + piece.text = cell(PieceValue); break; case DividerPiece::Kind::Logo: { - piece.logo.path = cell(CentreValue); - const int height = cell(CentreSize).toInt(); + piece.logo.path = cell(PieceValue); + const int height = cell(PieceSize).toInt(); piece.logo.maxHeight = height > 0 ? height : 96; break; } @@ -1869,13 +1914,14 @@ void SectionEditor::readCentreFromTable(Section *target) const pieces.append(piece); } - target->dividerCentre = pieces; + dividerPieces(*target, slot) = pieces; } -void SectionEditor::applyCentreRowVisibility(int row) +void SectionEditor::applyPieceRowVisibility(PieceSlot slot, int row) { - const auto *kindBox = qobject_cast(centreTable->cellWidget(row, CentreKind)); - auto *shapeBox = qobject_cast(centreTable->cellWidget(row, CentreShape)); + QTableWidget *table = pieceTable(slot); + const auto *kindBox = qobject_cast(table->cellWidget(row, PieceKind)); + auto *shapeBox = qobject_cast(table->cellWidget(row, PieceShape)); if (!kindBox || !shapeBox) return; @@ -1896,8 +1942,8 @@ void SectionEditor::applyCentreRowVisibility(int row) * Greyed rather than blanked, so a cell that stops applying keeps what was typed into it * and gives it back when the kind is switched round again. */ - const auto setEditable = [this, row](int column, bool editable) { - QTableWidgetItem *item = centreTable->item(row, column); + const auto setEditable = [table, row](int column, bool editable) { + QTableWidgetItem *item = table->item(row, column); if (!item) return; @@ -1907,117 +1953,124 @@ void SectionEditor::applyCentreRowVisibility(int row) item->setFlags(flags); }; - setEditable(CentreValue, hasValue); - setEditable(CentreSize, hasSize); + setEditable(PieceValue, hasValue); + setEditable(PieceSize, hasSize); } -bool SectionEditor::centreUsesFile() const +bool SectionEditor::dividerUsesFile() const { - for (int row = 0; row < centreTable->rowCount(); ++row) { - const auto *kindBox = qobject_cast(centreTable->cellWidget(row, CentreKind)); - const auto *shapeBox = qobject_cast(centreTable->cellWidget(row, CentreShape)); - if (!kindBox || !shapeBox) - continue; - - if (static_cast(kindBox->currentData().toInt()) != DividerPiece::Kind::Ornament) - continue; - - if (dividerShapeUsesFile(static_cast(shapeBox->currentData().toInt()))) - return true; + for (QTableWidget *table : pieceTables) { + for (int row = 0; row < table->rowCount(); ++row) { + const auto *kindBox = qobject_cast(table->cellWidget(row, PieceKind)); + const auto *shapeBox = qobject_cast(table->cellWidget(row, PieceShape)); + if (!kindBox || !shapeBox) + continue; + + if (static_cast(kindBox->currentData().toInt()) != + DividerPiece::Kind::Ornament) + continue; + + if (dividerShapeUsesFile(static_cast(shapeBox->currentData().toInt()))) + return true; + } } return false; } /* - * The three that follow go through the model rather than shuffling cells, because the rows carry + * The four that follow go through the model rather than shuffling cells, because the rows carry * combo boxes and QTableWidget::takeItem knows nothing about those. Round-tripping through * setSection is the same move importCsv makes, and for the same reason: it is the one path that * cannot leave the table and the section disagreeing. */ -void SectionEditor::addCentrePiece() +void SectionEditor::addPiece(PieceSlot slot) { Section updated = section(); - updated.dividerCentre.append(DividerPiece{}); + dividerPieces(updated, slot).append(DividerPiece{}); + const int added = dividerPieces(updated, slot).size() - 1; setSection(updated); - centreTable->setCurrentCell(updated.dividerCentre.size() - 1, CentreKind); + pieceTable(slot)->setCurrentCell(added, PieceKind); emitChanged(); } -void SectionEditor::removeSelectedCentrePieces() +void SectionEditor::removeSelectedPieces(PieceSlot slot) { QList rows; - for (const QModelIndex &index : centreTable->selectionModel()->selectedRows()) + for (const QModelIndex &index : pieceTable(slot)->selectionModel()->selectedRows()) rows.append(index.row()); if (rows.isEmpty()) return; Section updated = section(); + QVector &pieces = dividerPieces(updated, slot); /* Remove from the bottom up so earlier indices stay valid. */ std::sort(rows.begin(), rows.end(), std::greater()); for (int row : rows) { - if (row >= 0 && row < updated.dividerCentre.size()) - updated.dividerCentre.removeAt(row); + if (row >= 0 && row < pieces.size()) + pieces.removeAt(row); } setSection(updated); emitChanged(); } -void SectionEditor::moveSelectedCentrePiece(int delta) +void SectionEditor::movePiece(PieceSlot slot, int delta) { - const int row = centreTable->currentRow(); - const int target = row + delta; + const int row = pieceTable(slot)->currentRow(); Section updated = section(); - if (row < 0 || row >= updated.dividerCentre.size() || target < 0 || target >= updated.dividerCentre.size()) + QVector &pieces = dividerPieces(updated, slot); + + const int target = row + delta; + if (row < 0 || row >= pieces.size() || target < 0 || target >= pieces.size()) return; - std::swap(updated.dividerCentre[row], updated.dividerCentre[target]); + pieces.swapItemsAt(row, target); setSection(updated); - centreTable->setCurrentCell(target, CentreKind); + pieceTable(slot)->setCurrentCell(target, PieceKind); emitChanged(); } -void SectionEditor::browseForCentreFile() +void SectionEditor::browseForPieceFile(PieceSlot slot) { - const int row = centreTable->currentRow(); + QTableWidget *table = pieceTable(slot); + const int row = table->currentRow(); if (row < 0) return; - const auto *kindBox = qobject_cast(centreTable->cellWidget(row, CentreKind)); - const auto *shapeBox = qobject_cast(centreTable->cellWidget(row, CentreShape)); + const auto *kindBox = qobject_cast(table->cellWidget(row, PieceKind)); + const auto *shapeBox = qobject_cast(table->cellWidget(row, PieceShape)); if (!kindBox || !shapeBox) return; const auto kind = static_cast(kindBox->currentData().toInt()); const auto shape = static_cast(shapeBox->currentData().toInt()); - /* Which dialog to open follows from the row: a mark wants an image, an ornament artwork. */ - const bool logo = kind == DividerPiece::Kind::Logo; - if (!logo && !(kind == DividerPiece::Kind::Ornament && dividerShapeUsesFile(shape))) + /* A logo piece wants a picture, a custom ornament its artwork; nothing else takes a file. */ + const bool logoPiece = kind == DividerPiece::Kind::Logo; + if (!logoPiece && !(kind == DividerPiece::Kind::Ornament && dividerShapeUsesFile(shape))) return; - const QTableWidgetItem *existing = centreTable->item(row, CentreValue); + const QTableWidgetItem *existing = table->item(row, PieceValue); const QString path = QFileDialog::getOpenFileName( - this, logo ? moduleText("Designer.ChooseLogo") : moduleText("Designer.ChooseDividerSvg"), - existing ? existing->text() : QString(), logo ? imageFilter() : svgFilter()); - + this, moduleText(logoPiece ? "Designer.ChooseLogo" : "Designer.ChooseDividerSvg"), + existing ? existing->text() : QString(), logoPiece ? imageFilter() : svgFilter()); if (path.isEmpty()) return; - centreTable->setItem(row, CentreValue, new QTableWidgetItem(path)); + table->setItem(row, PieceValue, new QTableWidgetItem(path)); - const QTableWidgetItem *size = centreTable->item(row, CentreSize); - if (logo && (!size || size->text().toInt() <= 0)) - centreTable->setItem(row, CentreSize, new QTableWidgetItem(QStringLiteral("96"))); + const QTableWidgetItem *size = table->item(row, PieceSize); + if (logoPiece && (!size || size->text().isEmpty())) + table->setItem(row, PieceSize, new QTableWidgetItem(QStringLiteral("96"))); /* setItem hands back a fresh item, so the row's flags are reapplied over the top of it. */ - applyCentreRowVisibility(row); + applyPieceRowVisibility(slot, row); } void SectionEditor::browseForDividerSvg(QLineEdit *target) diff --git a/src/ui/SectionEditor.hpp b/src/ui/SectionEditor.hpp index 2eda049..670f59a 100644 --- a/src/ui/SectionEditor.hpp +++ b/src/ui/SectionEditor.hpp @@ -34,11 +34,27 @@ class QPlainTextEdit; class QPushButton; class QSpinBox; class QTableWidget; +class QTabWidget; class QToolButton; class QVBoxLayout; namespace closingtime { +/* + * Which of a divider's three piece stacks an editor is acting on. + * + * They are the same kind of list -- an end and a middle both hold `DividerPiece`s -- so one table + * implementation serves all three and this is what says which one a call means. The order is the + * order they are drawn in, which is also the order the tabs read in. + */ +enum class PieceSlot { LeftEnd, Centre, RightEnd }; + +constexpr int kPieceSlotCount = 3; + +/* The stack a slot names, on a section. */ +const QVector ÷rPieces(const Section §ion, PieceSlot slot); +QVector ÷rPieces(Section §ion, PieceSlot slot); + /* * Everything one TextStyle carries: font, colour and alignment, plus how the glyphs are * filled and what is drawn around them. @@ -209,16 +225,18 @@ class SectionEditor : public QWidget { * about, and rebuilding from a reordered vector cannot leave a row's widgets behind while * its text moves. */ - void writeCentreToTable(const Section &source); - void readCentreFromTable(Section *target) const; - void addCentrePiece(); - void removeSelectedCentrePieces(); - void moveSelectedCentrePiece(int delta); - void browseForCentreFile(); + void writePiecesToTable(PieceSlot slot, const Section &source); + void readPiecesFromTable(PieceSlot slot, Section *target) const; + void addPiece(PieceSlot slot); + void removeSelectedPieces(PieceSlot slot); + void movePiece(PieceSlot slot, int delta); + void browseForPieceFile(PieceSlot slot); /* Shows the fields that apply to the piece in `row` and hides the rest. */ - void applyCentreRowVisibility(int row); - /* True when any piece of the centre stack draws its artwork from a file. */ - bool centreUsesFile() const; + void applyPieceRowVisibility(PieceSlot slot, int row); + /* True when any piece of any of the three stacks draws its artwork from a file. */ + bool dividerUsesFile() const; + + QTableWidget *pieceTable(PieceSlot slot) const { return pieceTables[static_cast(slot)]; } void addEntry(); void removeSelectedEntries(); @@ -280,11 +298,7 @@ class SectionEditor : public QWidget { /* Bridged rows only: draw a second line under each side of every row. */ QCheckBox *rowSubtitles = nullptr; - QComboBox *dividerCap = nullptr; - QLineEdit *dividerCapSvgPath = nullptr; QCheckBox *dividerMirrorEnds = nullptr; - QComboBox *dividerEndCap = nullptr; - QLineEdit *dividerEndCapSvgPath = nullptr; QComboBox *dividerArm = nullptr; QLineEdit *dividerArmSvgPath = nullptr; QSpinBox *dividerThickness = nullptr; @@ -295,9 +309,15 @@ class SectionEditor : public QWidget { QSpinBox *dividerRuleInset = nullptr; QCheckBox *dividerTint = nullptr; - QGroupBox *centreGroup = nullptr; - QTableWidget *centreTable = nullptr; - QToolButton *centreFileButton = nullptr; + /* + * The divider's three piece stacks, in PieceSlot order. One table each rather than one table + * and a selector, because they are the same kind of list and the tabs are what say so -- and + * because the right-hand end's tab can simply be taken away while the ends are mirrored. + */ + QGroupBox *dividerPiecesGroup = nullptr; + QTabWidget *pieceTabs = nullptr; + QTableWidget *pieceTables[kPieceSlotCount] = {}; + QToolButton *pieceFileButtons[kPieceSlotCount] = {}; QSpinBox *columns = nullptr; QSpinBox *columnGap = nullptr; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index aa5b3f6..76383a0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,6 +16,7 @@ target_sources( harness/main.cpp suites/AnimatedLogos.cpp suites/BridgedRows.cpp + suites/Dividers.cpp suites/Fonts.cpp suites/Layout.cpp suites/LogoRows.cpp diff --git a/tests/suites/Dividers.cpp b/tests/suites/Dividers.cpp new file mode 100644 index 0000000..adf7439 --- /dev/null +++ b/tests/suites/Dividers.cpp @@ -0,0 +1,203 @@ +/* +Closing Time +Copyright (C) 2026 Voidscape Development + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include "harness/Fixtures.hpp" +#include "harness/Harness.hpp" +#include "harness/Probe.hpp" + +using namespace closingtime; +using namespace closingtime::test; + +namespace { + +DividerPiece ornament(DividerShape shape, double scale = 1.0) +{ + return DividerPiece{DividerPiece::Kind::Ornament, shape, {}, scale, {}, {}}; +} + +DividerPiece word(const QString &text) +{ + return DividerPiece{DividerPiece::Kind::Text, DividerShape::None, {}, 1.0, text, {}}; +} + +/* A divider with nothing but its own artwork in the document, and no padding to subtract. */ +Section divider() +{ + Section section = unpadded(SectionType::SectionDivider); + section.sectionWidth = 1.0; + section.marginX = 0; + section.dividerCap.clear(); + section.dividerEndCap.clear(); + section.dividerCentre.clear(); + section.dividerArm = DividerShape::Rule; + section.dividerThickness = 6.0; + return section; +} + +} // namespace + +CT_SUITE(divider_shape_parity, "Every piece shape offered wherever a piece can go") +{ + /* + * An end and a middle hold the same kind of piece, so they draw on the same list. The point + * of the check is the crossover: a shape the library used to offer only as an end has to + * turn up in the middle, and one it used to offer only as a centrepiece has to turn up at + * an end. + */ + const QVector &pieces = dividerShapesForRole(DividerRolePiece); + + check(pieces.contains(DividerShape::Arrow), "an arrowhead can break a rule as well as cap one"); + check(pieces.contains(DividerShape::ScrollEnd), "so can a scroll end"); + check(pieces.contains(DividerShape::Heart), "and a heart can cap a rule as well as break one"); + check(pieces.contains(DividerShape::Filigree), "and so can a filigree"); + check(pieces.contains(DividerShape::None), "None is offered, since a slot may hold nothing"); + check(pieces.contains(DividerShape::Custom), "and so is a file of the user's own"); + + /* An arm is the one genuinely different job, and stays its own list. */ + const QVector &arms = dividerShapesForRole(DividerRoleArm); + check(arms.contains(DividerShape::Rule), "a rule is an arm"); + check(!arms.contains(DividerShape::Heart), "a heart is not"); + check(!pieces.contains(DividerShape::Rule), "and a rule is not a piece"); + + for (DividerShape shape : pieces) + check(dividerShapeHasRole(shape, DividerRolePiece), "the list agrees with the table"); +} + +CT_SUITE(divider_end_stack, "An end built from more than one piece") +{ + Section one = divider(); + one.dividerCap = {ornament(DividerShape::Diamond)}; + + Section three = divider(); + three.dividerCap = {ornament(DividerShape::Diamond), ornament(DividerShape::Dot), + ornament(DividerShape::Dot)}; + + const QRectF oneBox = boxOf(documentWith(one), LayoutBox::Kind::Divider); + const QRectF threeBox = boxOf(documentWith(three), LayoutBox::Kind::Divider); + check(!oneBox.isNull() && !threeBox.isNull(), "both dividers were placed"); + + /* + * The figure spans the section either way -- the arms take up the slack -- so what a longer + * end has to change is where the rule starts, not how wide the divider is. Measured as the + * gap between the leftmost ink and the first unbroken run of the rule's own thickness would + * be fragile; the plain statement is that a taller stack of pieces is a taller divider when + * the pieces are scaled up, and that the pieces are all drawn. + */ + checkNear(threeBox.width(), oneBox.width(), 1.0, "an end stack does not widen the divider"); + + Section scaled = three; + scaled.dividerCap = {ornament(DividerShape::Diamond, 2.0)}; + check(measure(documentWith(scaled)) > measure(documentWith(one)), "a scaled-up end piece makes it taller"); + + checkEq(boxesOutsideContent(documentWith(three)), 0, "nothing placed outside the content box"); +} + +CT_SUITE(divider_end_mirrored, "The right-hand end as the left one flipped") +{ + /* + * A deliberately lopsided end: a big diamond outside a small dot. Mirrored, the whole figure + * has to come out symmetric about the section's own centre -- which catches both halves of + * the rule at once, since the order has to reverse *and* each piece's artwork has to flip. + */ + Section section = divider(); + section.dividerCap = {ornament(DividerShape::Arrow, 1.5), ornament(DividerShape::Dot)}; + section.dividerMirrorEnds = true; + + const Document document = documentWith(section); + const QImage image = renderImage(document); + + int asymmetric = 0; + for (int x = 0; x < image.width() / 2; ++x) { + if (inksColumn(image, x) != inksColumn(image, image.width() - 1 - x)) + ++asymmetric; + } + checkEq(asymmetric, 0, "a mirrored divider inks the same columns at both ends"); + + /* And an end of its own is still drawn at the right-hand side rather than left off. */ + Section separate = section; + separate.dividerMirrorEnds = false; + separate.dividerEndCap = {ornament(DividerShape::Diamond, 3.0)}; + + const Ink ink = inkOf(renderImage(documentWith(separate))); + const Ink mirroredInk = inkOf(image); + check(!ink.isEmpty(), "a divider with two different ends still draws"); + check(ink.height() > mirroredInk.height(), "and the taller far end is what sets its height"); +} + +CT_SUITE(divider_end_words, "A word and a picture at the end of a rule") +{ + /* + * The parity that matters most: what the middle could always hold, an end can hold too. A + * word at an end has to be drawn, has to count towards the fonts the roll uses, and must not + * be flipped along with the ornaments around it. + */ + Section section = divider(); + section.dividerCap = {word(QStringLiteral("MMXXVI"))}; + section.dividerMirrorEnds = true; + section.style.family = QStringLiteral("Sans Serif"); + + const Document document = documentWith(section); + check(document.usedFontFamilies().contains(QStringLiteral("Sans Serif")), + "a word at an end counts towards the roll's fonts"); + + const QImage image = renderImage(document); + const QRectF box = boxOf(document, LayoutBox::Kind::Divider); + check(!box.isNull(), "the divider was placed"); + + /* Ink at both ends: the word is drawn at each, not only where it was written. */ + const Ink leftInk = inkOf(image, QRectF(box.left(), box.top(), box.width() / 4.0, box.height())); + const Ink rightInk = inkOf(image, QRectF(box.right() - box.width() / 4.0, box.top(), box.width() / 4.0, + box.height())); + check(!leftInk.isEmpty(), "the word is drawn at the left-hand end"); + check(!rightInk.isEmpty(), "and at the right-hand one"); + + /* + * Type is not mirrored, so the two ends are *not* pixel-for-pixel reflections here -- which + * is the whole point of leaving words and pictures unflipped. A run of asymmetric columns is + * what proves it, against the run of zero the ornament-only divider above measures. + */ + int asymmetric = 0; + for (int x = 0; x < image.width() / 2; ++x) { + if (inksColumn(image, x) != inksColumn(image, image.width() - 1 - x)) + ++asymmetric; + } + check(asymmetric > 0, "a word at an end is drawn the right way round rather than reflected"); +} + +CT_SUITE(divider_end_empty, "An end with nothing on it") +{ + /* + * The ordinary case, and the one a document written before ends were stacks migrates to: an + * empty end is a rule that runs to the edge of the section rather than a hole where a piece + * would have been. + */ + Section bare = divider(); + const Document document = documentWith(bare); + const QRectF box = boxOf(document, LayoutBox::Kind::Divider); + const Ink ink = inkOf(renderImage(document)); + + check(!ink.isEmpty(), "a divider with no ends still draws its rule"); + checkNear(ink.left, box.left(), 2.0, "the rule reaches the left edge of the section"); + checkNear(ink.right, box.right(), 2.0, "and the right one"); + + /* A piece that measures to nothing is dropped along with its gap, wherever it sits. */ + Section empties = divider(); + empties.dividerCap = {word(QString()), ornament(DividerShape::None)}; + checkNear(inkOf(renderImage(documentWith(empties))).left, ink.left, 2.0, + "pieces that draw nothing take no room at an end"); +} diff --git a/tests/suites/Persistence.cpp b/tests/suites/Persistence.cpp index e5549c0..4aa2438 100644 --- a/tests/suites/Persistence.cpp +++ b/tests/suites/Persistence.cpp @@ -58,9 +58,11 @@ Section distinctive(SectionType type) section.bridgeSpanEmpty = true; section.bridgeMinGap = 33.5; section.rowSubtitles = true; - section.dividerCap = DividerShape::Diamond; + section.dividerCap = { + DividerPiece{DividerPiece::Kind::Ornament, DividerShape::Diamond, {}, 2.25, {}, {}}, + DividerPiece{DividerPiece::Kind::Text, DividerShape::None, {}, 1.0, QStringLiteral("MMXXVI"), {}}}; section.dividerMirrorEnds = false; - section.dividerEndCap = DividerShape::Dot; + section.dividerEndCap = {DividerPiece{DividerPiece::Kind::Ornament, DividerShape::Dot, {}, 1.0, {}, {}}}; section.dividerArm = DividerShape::Rule; section.dividerThickness = 6.5; section.dividerGap = 15.5; @@ -123,9 +125,19 @@ void compare(const Section &loaded, const Section &original) check(loaded.bridgeSpanEmpty == original.bridgeSpanEmpty, "bridgeSpanEmpty"); checkNear(loaded.bridgeMinGap, original.bridgeMinGap, 0.001, "bridgeMinGap"); check(loaded.rowSubtitles == original.rowSubtitles, "rowSubtitles"); - check(loaded.dividerCap == original.dividerCap, "dividerCap"); + checkEq(loaded.dividerCap.size(), original.dividerCap.size(), "dividerCap size"); + for (int i = 0; i < std::min(loaded.dividerCap.size(), original.dividerCap.size()); ++i) { + const DividerPiece &piece = loaded.dividerCap.at(i); + const DividerPiece &was = original.dividerCap.at(i); + check(piece.kind == was.kind, QStringLiteral("dividerCap %1 kind").arg(i)); + check(piece.shape == was.shape, QStringLiteral("dividerCap %1 shape").arg(i)); + checkNear(piece.scale, was.scale, 0.001, QStringLiteral("dividerCap %1 scale").arg(i)); + checkEq(piece.text, was.text, QStringLiteral("dividerCap %1 text").arg(i)); + } check(loaded.dividerMirrorEnds == original.dividerMirrorEnds, "dividerMirrorEnds"); - check(loaded.dividerEndCap == original.dividerEndCap, "dividerEndCap"); + checkEq(loaded.dividerEndCap.size(), original.dividerEndCap.size(), "dividerEndCap size"); + if (!loaded.dividerEndCap.isEmpty() && !original.dividerEndCap.isEmpty()) + check(loaded.dividerEndCap.first().shape == original.dividerEndCap.first().shape, "dividerEndCap shape"); check(loaded.dividerArm == original.dividerArm, "dividerArm"); checkNear(loaded.dividerThickness, original.dividerThickness, 0.001, "dividerThickness"); checkNear(loaded.dividerGap, original.dividerGap, 0.001, "dividerGap"); @@ -235,12 +247,38 @@ CT_SUITE(persistence_legacy, "Documents written before a field existed, and stor checkNear(legacy.bridgeThickness, 4.0, 0.001, "bridgeThickness falls back to something drawable"); check(legacy.bridgeTint, "bridgeTint falls back to on"); checkNear(legacy.bridgeSplit, 0.5, 0.001, "bridgeSplit falls back to an even split"); - check(legacy.dividerCap == DividerShape::None, "an absent divider cap is None"); + check(legacy.dividerCap.isEmpty(), "an absent divider cap is an end with nothing on it"); check(legacy.dividerArm == DividerShape::Rule, "an absent divider arm is the plain rule, not None"); check(legacy.dividerMirrorEnds, "divider ends are mirrored by default"); checkEq(legacy.dividerRules, 1, "a divider has at least one rule"); checkNear(legacy.sectionWidth, 1.0, 0.001, "sectionWidth falls back to the full canvas"); + /* + * An end written as a single shape, which is every document from before an end was a stack. + * It has to come back as the one-piece stack that draws the same divider. + */ + OBSDataAutoRelease oldEnds = obs_data_create(); + obs_data_set_string(oldEnds, "type", "section_divider"); + obs_data_set_string(oldEnds, "divider_cap", "arrow"); + obs_data_set_bool(oldEnds, "divider_mirror_ends", false); + obs_data_set_string(oldEnds, "divider_end_cap", "custom"); + obs_data_set_string(oldEnds, "divider_end_cap_svg", "/art/end.svg"); + + Section migrated; + migrated.load(oldEnds); + + checkEq(migrated.dividerCap.size(), 1, "a single cap shape migrates to one piece"); + if (!migrated.dividerCap.isEmpty()) { + check(migrated.dividerCap.first().kind == DividerPiece::Kind::Ornament, "and it is an ornament"); + check(migrated.dividerCap.first().shape == DividerShape::Arrow, "carrying the shape it named"); + checkNear(migrated.dividerCap.first().scale, 1.0, 0.001, "at its own size"); + } + checkEq(migrated.dividerEndCap.size(), 1, "so does the far end"); + if (!migrated.dividerEndCap.isEmpty()) { + check(migrated.dividerEndCap.first().shape == DividerShape::Custom, "keeping its custom shape"); + checkEq(migrated.dividerEndCap.first().svgPath, QStringLiteral("/art/end.svg"), "and its file"); + } + /* * Every one of these is a value a user can legitimately store, so the loader has to tell a * stored zero apart from a missing key rather than infer one from the other. From 5a4255f3bde1d5508ca6c06c8629b750305b6a45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:33:28 +0000 Subject: [PATCH 3/7] Add a Sticky Ending Block, and ask before removing a section A roll could only ever scroll past. What it could not do is hold: a closing card that stays put on the frame while the last of the credits runs up behind it, and then ends the roll when it has been up long enough. `StickyBlock` is a section that holds other sections. It is laid into the roll like any other -- it takes exactly the room its content would have taken inline, so nothing above or below it moves -- and travels up with the roll until its slot reaches the anchor. Then it detaches. The strip is one tall picture that scrolls, and a thing that stops scrolling while the rest of it carries on cannot be part of that picture, so the block is not painted into the strip at all: the slot is left empty and the block is handed out as a picture of its own, drawn over the strip by whoever composites. The same bargain an animated logo strikes, and for the same reason. The pin is a pair of points -- one on the block, one down the canvas -- plus a nudge, because "the middle of the block, halfway down the frame" needs both halves and a single number can only say one of them. The share is of the canvas height rather than a pixel offset, so a roll survives being resized. What happens when the hold runs out is a setting, since it answers two independent questions: the block stays and ends the roll, it carries on up and off and the roll ends as it always did, or both at once. `advance` keeps its own job -- a block that still has something to do simply holds the roll open, and finishes it itself when its hold is over. A backdrop is painted into the block's own picture rather than drawn as a quad behind it: libobs draws solids and textures from different effects, and a rectangle costs nothing at rebuild time. In the designer a block's children are indented under it in the section list, and a drag decides its container by the row it lands under -- drop it beneath a block or one of its rows and it joins the block, drop it anywhere else and it is top-level. That is one gesture for putting a section in and taking it back out. A section list that is no longer flat means every operation on the selected row now goes through a path rather than an index. Deleting a section now asks first, naming it, with a "don't ask again" for the window: undo has always covered it, but a misclick in a column of short labels is not a thing to find out about from the preview. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SctZkVkj2RxyH7oJUvH6wQ --- data/locale/en-US.ini | 28 +++ src/model/CreditsModel.cpp | 207 ++++++++++++++++++++-- src/model/CreditsModel.hpp | 114 ++++++++++++ src/render/StripRenderer.cpp | 210 +++++++++++++++++++---- src/render/StripRenderer.hpp | 67 ++++++++ src/source/CreditsSource.cpp | 276 +++++++++++++++++++++++++++++ src/ui/DesignerDialog.cpp | 315 +++++++++++++++++++++++++++++----- src/ui/DesignerDialog.hpp | 54 +++++- src/ui/PreviewWidget.cpp | 28 +++ src/ui/PreviewWidget.hpp | 7 +- src/ui/SectionEditor.cpp | 126 +++++++++++++- src/ui/SectionEditor.hpp | 13 ++ tests/CMakeLists.txt | 1 + tests/suites/Layout.cpp | 27 ++- tests/suites/Persistence.cpp | 29 ++++ tests/suites/SectionTypes.cpp | 1 + tests/suites/StickyBlocks.cpp | 263 ++++++++++++++++++++++++++++ 17 files changed, 1676 insertions(+), 90 deletions(-) create mode 100644 tests/suites/StickyBlocks.cpp diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 61f6a5d..b40f1ed 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -62,6 +62,9 @@ Designer.Fonts.Tip="What this roll does about its fonts on a machine that does n Designer.Add="Add" Designer.Duplicate="Duplicate" Designer.Remove="Remove" +Designer.RemoveSection.Title="Remove Section" +Designer.RemoveSection.Confirm="Remove “%1” from the roll?" +Designer.RemoveSection.DontAsk="Don't ask again while this window is open" Designer.MoveUp="Move Up" Designer.MoveDown="Move Down" Designer.ImportJson="Import JSON..." @@ -184,6 +187,31 @@ Designer.DividerShape.filigree="Filigree curls" Designer.DividerShape.deco_stack="Deco interlock" Designer.DividerShape.custom="Your own SVG file" Designer.SpacerHeight="Spacer Height" +Designer.Seconds=" s" +Designer.StickyAnchor="Pin This Part Of The Block" +Designer.StickyAnchor.Top="Its top edge" +Designer.StickyAnchor.Center="Its middle" +Designer.StickyAnchor.Bottom="Its bottom edge" +Designer.StickyAnchor.Tip="Which part of the block lands on the position below. Its middle at 50% is a block centred in the frame; its top edge at 0% is a block that stops with its first line at the top of the frame." +Designer.StickyCanvasPosition="Down The Frame" +Designer.StickyCanvasPosition.Tip="How far down the frame the pinned part lands — 0% is the top edge, 100% the bottom. A share of the height rather than a pixel count, so the roll still pins where it was meant to if the canvas is resized." +Designer.StickyOffset="Nudge" +Designer.StickyOffset.Tip="Moves the pinned block up or down by a few pixels, for when the share above is nearly right." +Designer.StickyHold="Hold For" +Designer.StickyHold.Tip="How long the block stays pinned before its release takes over. The rest of the roll goes on scrolling behind it the whole time." +Designer.StickyHoldForever="Hold until something else stops the roll" +Designer.StickyHoldForever.Tip="Keeps the block pinned indefinitely — for a card that stays up until a hotkey, a scene change or the stream ending takes it away." +Designer.StickyHoldForever.Warning="This block holds for ever and is set to end the roll when its hold runs out, so the roll never ends on its own and the ending action never fires." +Designer.StickyRelease="When The Hold Ends" +Designer.StickyRelease.EndAtHold="That is the end — stay put and fire the ending action" +Designer.StickyRelease.ResumeThenEnd="Carry on up and off, then end the roll as usual" +Designer.StickyRelease.ResumeEndAtHold="Carry on up and off, but fire the ending action now" +Designer.StickyRelease.Tip="What the block does when its hold runs out, and what counts as the end of the roll. Whichever is chosen, the rest of the roll keeps scrolling behind the block while it is pinned." +Designer.StickyBackdrop="Draw a panel behind the block" +Designer.StickyBackdrop.Tip="Fills the frame behind the block while it is pinned, so the credits still scrolling past underneath do not read through its lettering." +Designer.StickyBackdropColor="Panel Colour" +Designer.StickyBackdropPadding="Panel Padding" +Designer.StickyBackdropPadding.Tip="How far the panel reaches past the block, above and below." Designer.PaddingTop="Padding Above" Designer.PaddingBottom="Padding Below" Designer.MarginX="Side Margins" diff --git a/src/model/CreditsModel.cpp b/src/model/CreditsModel.cpp index c973731..6cad477 100644 --- a/src/model/CreditsModel.cpp +++ b/src/model/CreditsModel.cpp @@ -82,6 +82,12 @@ const SectionTypeInfo kSectionTypes[] = { */ {SectionType::SectionDivider, "section_divider", "Section Divider", false, false, false, false, false}, {SectionType::Spacer, "spacer", "Spacer", false, false, false, false, false}, + /* + * Every flag is false for the same reason a divider's are: a sticky block has no content of + * its own at all. What it holds is whole sections, each of which answers these questions for + * itself, so a block claiming to carry text or a list would be claiming its children's. + */ + {SectionType::StickyBlock, "sticky_block", "Sticky Ending Block", false, false, false, false, false}, }; /* Listed in the order the divider's centre-piece picker presents them. */ @@ -181,6 +187,24 @@ bool sectionUsesSubtitles(SectionType type) return sectionTypeInfo(type).subtitles; } +void visitSections(const QVector
§ions, const std::function &visit) +{ + for (const Section §ion : sections) { + visit(section); + for (const Section &child : section.children) + visit(child); + } +} + +void visitSections(QVector
§ions, const std::function &visit) +{ + for (Section §ion : sections) { + visit(section); + for (Section &child : section.children) + visit(child); + } +} + bool sectionStacksSubtitles(const Section §ion) { /* @@ -240,6 +264,81 @@ const QVector &allDividerPieceKinds() return kinds; } +const char *stickyAnchorId(StickyAnchor anchor) +{ + switch (anchor) { + case StickyAnchor::Top: + return "top"; + case StickyAnchor::Bottom: + return "bottom"; + case StickyAnchor::Center: + default: + return "center"; + } +} + +StickyAnchor stickyAnchorFromId(const char *id, StickyAnchor fallback) +{ + if (!id) + return fallback; + if (strcmp(id, "top") == 0) + return StickyAnchor::Top; + if (strcmp(id, "bottom") == 0) + return StickyAnchor::Bottom; + if (strcmp(id, "center") == 0) + return StickyAnchor::Center; + return fallback; +} + +double stickyAnchorFraction(StickyAnchor anchor) +{ + switch (anchor) { + case StickyAnchor::Top: + return 0.0; + case StickyAnchor::Bottom: + return 1.0; + case StickyAnchor::Center: + default: + return 0.5; + } +} + +const char *stickyReleaseId(StickyRelease release) +{ + switch (release) { + case StickyRelease::ResumeThenEnd: + return "resume_then_end"; + case StickyRelease::ResumeEndAtHold: + return "resume_end_at_hold"; + case StickyRelease::EndAtHold: + default: + return "end_at_hold"; + } +} + +StickyRelease stickyReleaseFromId(const char *id, StickyRelease fallback) +{ + if (!id) + return fallback; + if (strcmp(id, "resume_then_end") == 0) + return StickyRelease::ResumeThenEnd; + if (strcmp(id, "resume_end_at_hold") == 0) + return StickyRelease::ResumeEndAtHold; + if (strcmp(id, "end_at_hold") == 0) + return StickyRelease::EndAtHold; + return fallback; +} + +bool stickyReleaseResumes(StickyRelease release) +{ + return release != StickyRelease::EndAtHold; +} + +bool stickyReleaseEndsAtHold(StickyRelease release) +{ + return release != StickyRelease::ResumeThenEnd; +} + const char *hAlignId(HAlign align) { switch (align) { @@ -741,6 +840,15 @@ void Section::save(obs_data_t *data) const obs_data_set_double(data, "section_width", sectionWidth); obs_data_set_string(data, "section_align", hAlignId(sectionAlign)); obs_data_set_int(data, "spacer_height", spacerHeight); + obs_data_set_string(data, "sticky_anchor", stickyAnchorId(stickyAnchor)); + obs_data_set_double(data, "sticky_canvas_position", stickyCanvasPosition); + obs_data_set_double(data, "sticky_offset", stickyOffset); + obs_data_set_double(data, "sticky_hold", stickyHold); + obs_data_set_bool(data, "sticky_hold_forever", stickyHoldForever); + obs_data_set_string(data, "sticky_release", stickyReleaseId(stickyRelease)); + obs_data_set_bool(data, "sticky_backdrop", stickyBackdrop); + obs_data_set_int(data, "sticky_backdrop_color", static_cast(stickyBackdropColor.rgba())); + obs_data_set_double(data, "sticky_backdrop_padding", stickyBackdropPadding); obs_data_set_bool(data, "visible", visible); obs_data_set_bool(data, "use_secondary_style", useSecondaryStyle); obs_data_set_bool(data, "use_bridge_style", useBridgeStyle); @@ -799,6 +907,17 @@ void Section::save(obs_data_t *data) const */ savePieces("divider_cap_pieces", dividerCap); savePieces("divider_end_cap_pieces", dividerEndCap); + + /* + * A sticky block's children are sections, saved by the very same call that saved this one. + * Only one level deep can ever be written, because only one level can ever be held. + */ + saveArray( + data, "children", static_cast(children.size()), + [](obs_data_t *item, int index, const void *context) { + static_cast *>(context)->at(static_cast(index)).save(item); + }, + &children); } void Section::load(obs_data_t *data) @@ -913,6 +1032,29 @@ void Section::load(obs_data_t *data) sectionWidth = std::clamp(sectionWidth, 0.0, 1.0); sectionAlign = hAlignFromId(obs_data_get_string(data, "section_align"), HAlign::Center); spacerHeight = static_cast(obs_data_get_int(data, "spacer_height")); + stickyAnchor = stickyAnchorFromId(obs_data_get_string(data, "sticky_anchor"), StickyAnchor::Center); + /* + * 0.0 is the top of the canvas and a perfectly ordinary place to pin something, so a missing + * key has to be told apart from a stored zero here as everywhere else. + */ + stickyCanvasPosition = obs_data_has_user_value(data, "sticky_canvas_position") + ? obs_data_get_double(data, "sticky_canvas_position") + : 0.5; + stickyCanvasPosition = std::clamp(stickyCanvasPosition, 0.0, 1.0); + stickyOffset = obs_data_get_double(data, "sticky_offset"); + stickyHold = obs_data_has_user_value(data, "sticky_hold") ? obs_data_get_double(data, "sticky_hold") : 5.0; + stickyHold = std::max(0.0, stickyHold); + stickyHoldForever = obs_data_get_bool(data, "sticky_hold_forever"); + stickyRelease = stickyReleaseFromId(obs_data_get_string(data, "sticky_release"), StickyRelease::EndAtHold); + stickyBackdrop = obs_data_get_bool(data, "sticky_backdrop"); + if (obs_data_has_user_value(data, "sticky_backdrop_color")) { + stickyBackdropColor = QColor::fromRgba( + static_cast(obs_data_get_int(data, "sticky_backdrop_color"))); + } + stickyBackdropPadding = obs_data_has_user_value(data, "sticky_backdrop_padding") + ? obs_data_get_double(data, "sticky_backdrop_padding") + : 24.0; + stickyBackdropPadding = std::max(0.0, stickyBackdropPadding); visible = obs_data_get_bool(data, "visible"); useSecondaryStyle = obs_data_get_bool(data, "use_secondary_style"); /* Absent in every document written before the bridge had ink of its own, all of which took the row's. */ @@ -1050,6 +1192,29 @@ void Section::load(obs_data_t *data) loadEnd("divider_cap_pieces", "divider_cap", "divider_cap_svg", ÷rCap); loadEnd("divider_end_cap_pieces", "divider_end_cap", "divider_end_cap_svg", ÷rEndCap); + + children.clear(); + OBSDataArrayAutoRelease childArray = obs_data_get_array(data, "children"); + if (childArray) { + const size_t count = obs_data_array_count(childArray); + children.reserve(count); + for (size_t i = 0; i < count; ++i) { + OBSDataAutoRelease item = obs_data_array_item(childArray, i); + + Section child; + child.load(item); + /* + * A sticky block inside a sticky block is not something the designer can + * make, and is dropped rather than loaded: everything downstream -- the + * layout, the playback, the pinned quad -- is written for one level, and a + * hand-written document is not a reason to find out what two would do. + */ + if (child.type == SectionType::StickyBlock) + continue; + + children.push_back(child); + } + } } Section Section::makeDefault(SectionType type) @@ -1215,6 +1380,19 @@ Section Section::makeDefault(SectionType type) section.paddingTop = 0; section.paddingBottom = 0; break; + + case SectionType::StickyBlock: + /* + * A closing card to start from, because an empty block is invisible in the preview and + * gives the reader nothing to drag other sections next to. Held in the middle of the + * frame for five seconds and then treated as the end of the roll, which is the thing + * the type is named after; everything else about it is one control away. + */ + section.children.push_back(makeDefault(SectionType::Title)); + section.children.front().text = QStringLiteral("The End"); + section.paddingTop = 0; + section.paddingBottom = 0; + break; } /* @@ -1301,6 +1479,15 @@ QString Section::displayLabel() const case SectionType::Spacer: return QStringLiteral("%1 (%2 px)").arg(QString::fromUtf8(sectionTypeName(type))).arg(spacerHeight); + case SectionType::StickyBlock: + /* + * Named by what it holds rather than by how long it holds for: the list is a list of + * content, and the children are indented under it saying the same thing in more detail. + */ + return QStringLiteral("%1 (%2)") + .arg(QString::fromUtf8(sectionTypeName(type))) + .arg(static_cast(children.size())); + default: break; } @@ -1432,7 +1619,7 @@ bool Document::applyLibraryRenames() preset.name = renamed; /* Every binding that named it, or the roll would follow the rename into nothing. */ - for (Section §ion : sections) { + visitSections(sections, [&](Section §ion) { if (section.stylePresetName == from) section.stylePresetName = renamed; if (section.secondaryStylePresetName == from) @@ -1443,7 +1630,7 @@ bool Document::applyLibraryRenames() section.rowSubtitleStylePresetName = renamed; if (section.rowSecondarySubtitleStylePresetName == from) section.rowSecondarySubtitleStylePresetName = renamed; - } + }); changed = true; } @@ -1495,7 +1682,7 @@ void Document::removeStylePreset(const QString &name) * section's own style either way, but clearing them means a later preset that happens * to reuse the name does not silently recapture sections the user had unbound. */ - for (Section §ion : sections) { + visitSections(sections, [&name](Section §ion) { if (section.stylePresetName == name) section.stylePresetName.clear(); if (section.secondaryStylePresetName == name) @@ -1506,7 +1693,7 @@ void Document::removeStylePreset(const QString &name) section.rowSubtitleStylePresetName.clear(); if (section.rowSecondarySubtitleStylePresetName == name) section.rowSecondarySubtitleStylePresetName.clear(); - } + }); } QStringList Document::usedFontFamilies() const @@ -1538,9 +1725,9 @@ QVector Document::usedFonts() const fonts.append(FontUse{style.family, {style.styleName}}); }; - for (const Section §ion : sections) { + visitSections(sections, [&](const Section §ion) { if (!section.visible) - continue; + return; /* * A divider draws text only when its centre stack holds some, so it is asked rather @@ -1559,7 +1746,7 @@ QVector Document::usedFonts() const holdsWord(section.dividerEndCap)); if (!sectionUsesText(section.type) && !dividerText) - continue; + return; consider(effectiveStyle(section)); consider(effectiveSecondaryStyle(section)); @@ -1573,7 +1760,7 @@ QVector Document::usedFonts() const consider(effectiveRowSubtitleStyle(section)); consider(effectiveRowSecondarySubtitleStyle(section)); } - } + }); std::sort(fonts.begin(), fonts.end(), [](const FontUse &left, const FontUse &right) { return left.family.compare(right.family, Qt::CaseInsensitive) < 0; @@ -1637,7 +1824,7 @@ bool Document::applyFontSubstitutions(const QStringList &families) for (StylePreset &preset : stylePresets) rewrite(preset.style); - for (Section §ion : sections) { + visitSections(sections, [&rewrite](Section §ion) { rewrite(section.style); rewrite(section.secondaryStyle); rewrite(section.rowSubtitleStyle); @@ -1646,7 +1833,7 @@ bool Document::applyFontSubstitutions(const QStringList &families) * `bridgeStyle` is deliberately left alone: a bridge keeps the row's own font and * takes only ink from it, so the family recorded there is never drawn with. */ - } + }); return changed; } diff --git a/src/model/CreditsModel.hpp b/src/model/CreditsModel.hpp index 6fbeade..4ece98a 100644 --- a/src/model/CreditsModel.hpp +++ b/src/model/CreditsModel.hpp @@ -26,6 +26,9 @@ with this program. If not, see #include #include +#include +#include + #include "model/BridgeArt.hpp" #include "model/DividerArt.hpp" #include "model/EndingAction.hpp" @@ -58,6 +61,11 @@ enum class SectionType { MultiLogoList, SectionDivider, Spacer, + /* + * A run of sections that pins itself to a place on the canvas instead of scrolling past it. + * It holds other sections rather than content of its own; see `Section::children`. + */ + StickyBlock, }; const char *sectionTypeId(SectionType type); @@ -91,6 +99,47 @@ bool sectionUsesSubtitles(SectionType type); */ bool sectionUsesSecondaryText(SectionType type); +/* + * Which part of a sticky block is pinned, and to what. + * + * The pin is a pair of points: one on the block and one down the canvas. Saying "the middle of the + * block, halfway down the frame" needs both halves, and a single number could only ever express + * one of them -- which is why a block's top edge landing at a fixed distance from the top of the + * frame and a block *centred* in the frame are two settings rather than one with a fudge in it. + */ +enum class StickyAnchor { Top, Center, Bottom }; + +const char *stickyAnchorId(StickyAnchor anchor); +StickyAnchor stickyAnchorFromId(const char *id, StickyAnchor fallback = StickyAnchor::Center); + +/* Where on the block the pin sits: 0 at its top edge, 1 at its bottom. */ +double stickyAnchorFraction(StickyAnchor anchor); + +/* + * What a sticky block does when its hold runs out. + * + * EndAtHold - the hold expiring *is* the end of the roll: the ending action fires and the + * block stays where it is. The closing card that stays on screen. + * ResumeThenEnd - the block carries on up and off the top, and the roll ends the way it always + * has, once everything has left the frame. + * ResumeEndAtHold- the block carries on up and off, but the ending action fires at the moment + * the hold ends rather than waiting for it to clear the frame. + * + * Three rather than one because they answer two independent questions -- does the block leave, and + * what counts as the end of the roll -- and every combination of the two is something somebody + * builds a roll around. + */ +enum class StickyRelease { EndAtHold, ResumeThenEnd, ResumeEndAtHold }; + +const char *stickyReleaseId(StickyRelease release); +StickyRelease stickyReleaseFromId(const char *id, StickyRelease fallback = StickyRelease::EndAtHold); + +/* True when the block scrolls on after its hold rather than staying where it was pinned. */ +bool stickyReleaseResumes(StickyRelease release); + +/* True when the hold running out is what finishes the roll, rather than the strip clearing. */ +bool stickyReleaseEndsAtHold(StickyRelease release); + enum class HAlign { Left, Center, Right }; const char *hAlignId(HAlign align); @@ -180,6 +229,18 @@ BridgeFill effectiveBridgeFill(const Section §ion); */ bool sectionStacksSubtitles(const Section §ion); +/* + * Every section in `sections`, the children of any sticky block among them included, in the order + * they are drawn. + * + * The document's list is no longer flat, and everything that walks it to ask a section something -- + * which fonts does this roll use, which preset does this section bind to -- has to reach a child or + * quietly stop working for anything inside a block. One walk in one place is what keeps a new + * question from being asked of half the roll. + */ +void visitSections(const QVector
§ions, const std::function &visit); +void visitSections(QVector
§ions, const std::function &visit); + /* * What the glyphs themselves are painted with. * @@ -812,6 +873,59 @@ struct Section { /* Spacer sections only: how tall the blank run is, in pixels. */ int spacerHeight = 120; + /* + * Sticky Ending Block sections only. + * + * The block is laid into the roll like any other section and scrolls up with it. When its + * slot reaches the anchor it detaches and stays there while the rest of the roll goes on + * past behind it -- which is why the renderer leaves a hole where it would have been and + * hands the block out as a picture of its own, the same bargain an animated logo strikes. + * + * `children` is what it holds. Any section type may go inside except another sticky block: + * pinning something to something already pinned is a second kind of position with a second + * set of rules, and one level is what the feature is for. The loader drops any that turn up, + * so a hand-written document cannot smuggle one in either. + * + * Held as std::vector rather than QVector because this is a member of the very type it holds: + * the standard says a vector may be declared over an incomplete type, and Qt's containers + * make no such promise. + */ + std::vector
children; + + /* Which point of the block is pinned, and where down the canvas that point lands. */ + StickyAnchor stickyAnchor = StickyAnchor::Center; + /* + * How far down the canvas the anchor sits, 0.0 at the top edge and 1.0 at the bottom. A + * share of the height rather than a pixel offset, so a roll designed at 1080 still pins + * where it was meant to when the canvas is resized under it. + */ + double stickyCanvasPosition = 0.5; + /* A nudge on the pinned position, in pixels. Positive moves the block down. */ + double stickyOffset = 0.0; + + /* How long the block holds once it has pinned, in seconds. */ + double stickyHold = 5.0; + /* + * Hold until something else stops the roll -- a hotkey, a scene change -- rather than for a + * measured time. A block that never lets go can never end the roll either, so the designer + * says as much next to the release setting rather than leaving it to be discovered on air. + */ + bool stickyHoldForever = false; + + StickyRelease stickyRelease = StickyRelease::EndAtHold; + + /* + * A panel drawn behind the block while it is pinned, so the roll running past underneath + * does not read through its lettering. + * + * Off by default: a closing card set over the last of the credits is a perfectly good look, + * and a backdrop that appeared without being asked for would be the plugin making that + * decision. `stickyBackdropPadding` grows it past the block's own bounds on every side. + */ + bool stickyBackdrop = false; + QColor stickyBackdropColor = QColor(0, 0, 0, 180); + double stickyBackdropPadding = 24.0; + bool visible = true; void save(obs_data_t *data) const; diff --git a/src/render/StripRenderer.cpp b/src/render/StripRenderer.cpp index 8e97ff7..cf8ba9b 100644 --- a/src/render/StripRenderer.cpp +++ b/src/render/StripRenderer.cpp @@ -657,6 +657,27 @@ struct BoxCollector { * the measure pass for the same reason the boxes are -- that pass visits each section once, * whatever tiles the section straddles. */ +/* + * Collects the sticky blocks the layout placed, the way AnimatedCollector collects animated logos. + * + * Only a caller that can actually draw a block over the strip asks for these -- the source and the + * designer's preview. `measure()` passes nothing and rasterises nothing, so asking a roll how tall + * it is stays as cheap as it was. + */ +struct StickyCollector { + QVector *placements = nullptr; + int section = -1; + + void add(StickyBlockPlacement placement) + { + if (!placements || placement.rect.isEmpty()) + return; + + placement.section = section; + placements->append(std::move(placement)); + } +}; + struct AnimatedCollector { QVector *placements = nullptr; int section = -1; @@ -1419,6 +1440,29 @@ DividerLayout layoutDivider(const Section §ion, const TextStyle &style, cons return layout; } +/* How far outside its own box one section can paint. Zero for a section that draws nothing. */ +double sectionBleed(const Document &document, const Section §ion) +{ + /* + * Logos cast the style's shadow as well, so a section that only places art counts + * too -- and a divider counts whatever its centre holds, since its rule is inked by + * the same style and can carry the same shadow with nothing but artwork in it. + */ + if (!section.visible || !(sectionUsesText(section.type) || sectionUsesLogos(section.type) || + section.type == SectionType::SectionDivider)) + return 0.0; + + double bleed = std::max(document.effectiveStyle(section).effectBleed(), + document.effectiveSecondaryStyle(section).effectBleed()); + /* + * A bridge inked separately can carry a heavier shadow than either text beside it, and + * it is counted for every section rather than only the bridged shapes: the override + * costs nothing to resolve for a section that never draws a bridge, and the alternative + * is this predicate and the layout switch's having to agree on which types those are. + */ + return std::max(bleed, document.effectiveBridgeStyle(section).effectBleed()); +} + /* * Both passes of the layout run through this one function. With `painter` set it draws * into the current tile; with `painter` null it only reports the height. `top` is the @@ -1430,7 +1474,34 @@ DividerLayout layoutDivider(const Section §ion, const TextStyle &style, cons */ int layoutSection(QPainter *painter, const Section §ion, const Document &document, const LogoSource &logos, BridgeArtCache *bridges, DividerArtCache *dividers, int top, BoxCollector *boxes = nullptr, - AnimatedCollector *animated = nullptr) + AnimatedCollector *animated = nullptr, StickyCollector *sticky = nullptr); + +/* + * Lays a sticky block's children out, one under the next, and returns the height they come to. + * + * The children go through the very same layout call every other section does -- they *are* sections + * -- so a block holds whatever the roll holds and nothing here has to know what any of it is. What + * they do not get is the sticky collector: a block cannot hold a block, and the loader drops any + * that tries. + */ +int layoutStickyChildren(QPainter *painter, const Section &block, const Document &document, const LogoSource &logos, + BridgeArtCache *bridges, DividerArtCache *dividers, int top, BoxCollector *boxes, + AnimatedCollector *animated) +{ + int y = top; + for (const Section &child : block.children) { + if (!child.visible) + continue; + + y += layoutSection(painter, child, document, logos, bridges, dividers, y, boxes, animated); + } + + return y - top; +} + +int layoutSection(QPainter *painter, const Section §ion, const Document &document, const LogoSource &logos, + BridgeArtCache *bridges, DividerArtCache *dividers, int top, BoxCollector *boxes, + AnimatedCollector *animated, StickyCollector *sticky) { /* Reads as one call at every site whether or not anyone is collecting. */ const auto record = [boxes](LayoutBox::Kind kind, const QRectF &rect) { @@ -1458,11 +1529,20 @@ int layoutSection(QPainter *painter, const Section §ion, const Document &doc * At the defaults -- the full width, centred -- the box is the canvas and this is exactly * the inset from both edges that it has always been. */ - const qreal boxWidth = std::clamp(section.sectionWidth, 0.0, 1.0) * document.width; - const int boxX = qRound(alignOffset(section.sectionAlign, document.width, boxWidth)); + /* + * A sticky block is the exception: it spans the canvas whatever these say, because what it + * holds is whole sections and each of them carries a box of its own. Two nested shares of the + * width would be two settings for one thing, and the inner one is the one that can already + * say everything the outer one could. + */ + const bool spansCanvas = section.type == SectionType::StickyBlock; + + const qreal boxWidth = spansCanvas ? document.width : std::clamp(section.sectionWidth, 0.0, 1.0) * document.width; + const int boxX = spansCanvas ? 0 : qRound(alignOffset(section.sectionAlign, document.width, boxWidth)); - const int contentX = boxX + section.marginX; - const int contentWidth = std::max(1, qRound(boxWidth) - section.marginX * 2); + const int contentX = spansCanvas ? 0 : boxX + section.marginX; + const int contentWidth = + spansCanvas ? document.width : std::max(1, qRound(boxWidth) - section.marginX * 2); /* Resolved once here so nothing below can accidentally bypass a preset binding. */ const TextStyle &style = document.effectiveStyle(section); @@ -1475,6 +1555,86 @@ int layoutSection(QPainter *painter, const Section §ion, const Document &doc y += section.spacerHeight; break; + case SectionType::StickyBlock: { + /* + * The block takes up its slot in the roll exactly as its content would have inline, so + * everything above and below it sits where it always did and the slot goes on scrolling + * after the block has detached from it. What it does *not* do is draw into the strip: + * with a painter this leaves the slot empty, and the block itself is handed to the + * compositor as a picture of its own. + */ + const int height = layoutStickyChildren(nullptr, section, document, logos, bridges, dividers, y, + boxes, animated); + record(LayoutBox::Kind::Sticky, QRectF(0, y, document.width, height)); + + if (sticky && height > 0) { + /* + * Rasterised at the strip's full width, because a child places itself across + * the canvas exactly as a top-level section does -- its own `sectionWidth` and + * placement are what narrow it, and they would mean something different if the + * block handed them a narrower canvas to sit in. + * + * The picture is grown by the roll's bleed at top and bottom: a shadow cast by + * the first or last child reaches outside the slot, and unlike the strip -- where + * the neighbouring tile catches it -- there is nothing outside this picture to + * catch it in. + */ + const int bleed = qCeil(sectionBleed(document, section)); + int blockBleed = bleed; + for (const Section &child : section.children) + blockBleed = std::max(blockBleed, qCeil(sectionBleed(document, child))); + + StickyBlockPlacement placement; + placement.rect = QRectF(0, y, document.width, height); + placement.anchor = section.stickyAnchor; + placement.canvasPosition = section.stickyCanvasPosition; + placement.offset = section.stickyOffset; + placement.hold = section.stickyHold; + placement.holdForever = section.stickyHoldForever; + placement.release = section.stickyRelease; + /* + * The backdrop's padding reaches outside the slot as surely as a shadow does, + * so the picture is grown by whichever of the two asks for more. + */ + const double backdropPadding = + section.stickyBackdrop ? std::max(0.0, section.stickyBackdropPadding) : 0.0; + const int margin = std::max(blockBleed, qCeil(backdropPadding)); + placement.margin = margin; + + QImage image(std::max(1, document.width), std::max(1, height + margin * 2), + QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + + QPainter blockPainter(&image); + blockPainter.setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing | + QPainter::SmoothPixmapTransform); + /* The children are laid out in strip space; shift the picture under them. */ + blockPainter.translate(0, -(y - margin)); + + /* + * The panel goes down first, under everything the block holds: it is there to + * keep the roll running past behind the block from reading through its + * lettering, which is a job it can only do from underneath. + */ + if (section.stickyBackdrop && section.stickyBackdropColor.alpha() > 0) { + blockPainter.fillRect(QRectF(0, y - backdropPadding, document.width, + height + backdropPadding * 2.0), + section.stickyBackdropColor); + } + + layoutStickyChildren(&blockPainter, section, document, logos, bridges, dividers, y, nullptr, + nullptr); + blockPainter.end(); + + /* Straight alpha, for the same reason the tiles are. */ + placement.image = image.convertToFormat(QImage::Format_ARGB32); + sticky->add(std::move(placement)); + } + + y += height; + break; + } + case SectionType::SectionDivider: { const DividerLayout divider = layoutDivider(section, style, logos, dividers, contentX, y, contentWidth); @@ -1855,39 +2015,28 @@ int effectBleed(const Document &document) { double bleed = 0.0; - for (const Section §ion : document.sections) { - /* - * Logos cast the style's shadow as well, so a section that only places art counts - * too -- and a divider counts whatever its centre holds, since its rule is inked by - * the same style and can carry the same shadow with nothing but artwork in it. - */ - if (!section.visible || !(sectionUsesText(section.type) || sectionUsesLogos(section.type) || - section.type == SectionType::SectionDivider)) - continue; - - bleed = std::max(bleed, document.effectiveStyle(section).effectBleed()); - bleed = std::max(bleed, document.effectiveSecondaryStyle(section).effectBleed()); - /* - * A bridge inked separately can carry a heavier shadow than either text beside it, and - * it is counted for every section rather than only the bridged shapes: the override - * costs nothing to resolve for a section that never draws a bridge, and the alternative - * is this predicate and the layout switch's having to agree on which types those are. - */ - bleed = std::max(bleed, document.effectiveBridgeStyle(section).effectBleed()); - } + /* + * A sticky block's own children are counted too. They are not drawn into the strip, so they + * cannot bleed across a tile seam -- but the block is rasterised into a picture of its own, + * and that picture has to be big enough to hold what they paint outside their boxes. + */ + visitSections(document.sections, + [&](const Section §ion) { bleed = std::max(bleed, sectionBleed(document, section)); }); return qCeil(bleed); } QVector placeSections(const Document &document, const LogoSource &logos, BridgeArtCache *bridges, DividerArtCache *dividers, int *totalHeight, LayoutBoxes *boxes = nullptr, - QVector *animated = nullptr) + QVector *animated = nullptr, + QVector *sticky = nullptr) { QVector placed; placed.reserve(document.sections.size()); BoxCollector collector{boxes, -1}; AnimatedCollector animatedCollector{animated, -1}; + StickyCollector stickyCollector{sticky, -1}; int y = document.leadIn; for (int index = 0; index < document.sections.size(); ++index) { @@ -1897,8 +2046,10 @@ QVector placeSections(const Document &document, const LogoSource collector.section = index; animatedCollector.section = index; + stickyCollector.section = index; const int height = layoutSection(nullptr, section, document, logos, bridges, dividers, y, - boxes ? &collector : nullptr, animated ? &animatedCollector : nullptr); + boxes ? &collector : nullptr, animated ? &animatedCollector : nullptr, + sticky ? &stickyCollector : nullptr); placed.append(PlacedSection{§ion, y, height}); y += height; @@ -2046,13 +2197,14 @@ Strip StripRenderer::render(const Document &input, LayoutBoxes *boxes) const int total = 0; const LogoSource art{logos, animations}; - const QVector placed = - placeSections(document, art, &bridges, ÷rs, &total, boxes, &strip.animatedLogos); + const QVector placed = placeSections(document, art, &bridges, ÷rs, &total, boxes, + &strip.animatedLogos, &strip.stickyBlocks); strip.height = std::min(std::max(total, 0), kMaxStripHeight); if (strip.height <= 0 || placed.isEmpty()) { /* Nothing is drawn over a strip that is not drawn. */ strip.animatedLogos.clear(); + strip.stickyBlocks.clear(); return strip; } diff --git a/src/render/StripRenderer.hpp b/src/render/StripRenderer.hpp index aa04a59..0696ce1 100644 --- a/src/render/StripRenderer.hpp +++ b/src/render/StripRenderer.hpp @@ -114,6 +114,61 @@ struct AnimatedLogoPlacement { int section = -1; }; +/* + * A sticky block the layout placed, and everything needed to draw it. + * + * The strip is one tall picture that scrolls; a block that stops scrolling while the rest of it + * carries on is by definition not part of that picture. So a sticky block is not painted into the + * strip at all -- the same bargain an animated logo strikes, and for the same reason. The slot it + * occupies is left empty and the block is carried here as a picture of its own, drawn over the + * strip by whoever is compositing: the source on the graphics thread, the designer's preview on + * the UI thread. + * + * The layout does not move for any of it. The block occupies exactly the slot its content would + * have occupied inline, so everything above and below it sits where it always did, and the empty + * slot goes on scrolling after the block has detached from it. + */ +struct StickyBlockPlacement { + /* The slot, in strip space. Consumers add the scroll offset to find where it would be. */ + QRectF rect; + /* The block itself, rasterised at the strip's width, exactly `rect` in size. */ + QImage image; + + /* Everything about how it pins, copied off the section so the compositor reads no model. */ + StickyAnchor anchor = StickyAnchor::Center; + double canvasPosition = 0.5; + double offset = 0.0; + double hold = 5.0; + bool holdForever = false; + StickyRelease release = StickyRelease::EndAtHold; + + /* + * How far past the slot the picture reaches at top and bottom, in pixels: enough for the + * shadows a child can cast outside its own box, and for the backdrop's padding when the block + * carries one. The picture is `rect.height() + margin * 2` tall and its top edge belongs + * `margin` pixels above wherever the block is drawn. + * + * The backdrop itself is painted into the picture rather than carried here as a colour to be + * drawn behind it. A flat quad would mean a second effect -- libobs draws solids and textures + * from different ones -- started inside the pass that is drawing the strip, and the panel is + * a rectangle the rasteriser can fill for nothing at rebuild time. + */ + int margin = 0; + + /* Index into Document::sections, for the designer's overlay and its highlighting. */ + int section = -1; + + /* + * Where the block's top edge sits in canvas space once it has pinned, for a block of this + * height on a canvas this tall. The one piece of arithmetic both the source and the preview + * need, kept here so they cannot disagree about where "pinned" is. + */ + double pinnedTop(int canvasHeight) const + { + return canvasPosition * canvasHeight + offset - stickyAnchorFraction(anchor) * rect.height(); + } +}; + struct Strip { QVector tiles; /* @@ -121,6 +176,12 @@ struct Strip { * straddles a tile seam is reported once rather than once per tile it touches. */ QVector animatedLogos; + /* + * Collected in the same pass, and for the same reason: a block straddling a tile seam is + * reported once rather than once per tile. Empty for a roll with no sticky blocks in it, + * and always empty from `measure()`, which rasterises nothing. + */ + QVector stickyBlocks; int width = 0; int height = 0; @@ -150,6 +211,12 @@ struct LayoutBox { Text, Logo, Bridge, + /* + * The slot a sticky block occupies in the roll. Worth its own kind rather than being + * another Section box: the block is drawn somewhere else entirely once it pins, and + * the overlay showing where its slot *was* is the whole point of drawing it. + */ + Sticky, /* * A Section Divider's own box, over the height its artwork occupies -- which is not * the same as its content box, since a divider narrower than its section sits diff --git a/src/source/CreditsSource.cpp b/src/source/CreditsSource.cpp index 83add26..6e59600 100644 --- a/src/source/CreditsSource.cpp +++ b/src/source/CreditsSource.cpp @@ -81,6 +81,40 @@ struct AnimatedLogoRuntime { uint64_t epoch = 0; }; +/* + * One sticky block, mid-roll. + * + * The strip left a slot where this block goes (see StickyBlockPlacement) and this is what fills + * it: one texture, drawn as its own quad after the tiles, at a position this decides rather than + * at the one the slot has scrolled to. That is the whole feature -- the block detaches from the + * roll it arrived on and the roll carries on past behind it. + * + * Graphics-thread state, like the tile textures beside it. + */ +struct StickyBlockRuntime { + StickyBlockPlacement placement; + gs_texture_t *texture = nullptr; + + enum class State { + /* Still travelling with the roll, on its way to the anchor. */ + Waiting, + /* Detached and holding at the anchor. */ + Pinned, + /* Let go and climbing off the top of the frame under its own steam. */ + Released, + }; + + State state = State::Waiting; + /* Seconds left of the hold. Meaningless until the block has pinned. */ + double holdRemaining = 0.0; + /* How far it has travelled since it was released, in pixels. */ + double releasedTravel = 0.0; + /* Set once its hold has had whatever say it has in ending the roll. */ + bool spent = false; + /* The roll pass this belongs to; a new one starts it over. */ + uint64_t epoch = 0; +}; + /* * Threading: * @@ -127,7 +161,14 @@ struct CreditsSourceData { std::vector tileTextures; std::vector tileTops; std::vector animatedLogos; + std::vector stickyBlocks; int stripHeight = 0; + /* + * True while a sticky block still has something to do before the roll can be called + * finished -- a hold that has not run out, or a released block still on screen. Written on + * the graphics thread each tick, before `advance` reads it, and both run only there. + */ + bool stickyPending = false; std::mutex stateMutex; Phase phase = Phase::Idle; @@ -302,10 +343,17 @@ void releaseTextures(CreditsSourceData *data) gs_texture_destroy(runtime.shadowTexture); } + for (StickyBlockRuntime &runtime : data->stickyBlocks) { + if (runtime.texture) + gs_texture_destroy(runtime.texture); + } + data->tileTextures.clear(); data->tileTops.clear(); data->animatedLogos.clear(); + data->stickyBlocks.clear(); data->stripHeight = 0; + data->stickyPending = false; } /* Graphics thread only; requires an active graphics context. */ @@ -392,6 +440,19 @@ void uploadPendingStrip(CreditsSourceData *data) data->animatedLogos.push_back(std::move(runtime)); } + data->stickyBlocks.reserve(strip.stickyBlocks.size()); + for (StickyBlockPlacement &placement : strip.stickyBlocks) { + StickyBlockRuntime runtime; + runtime.holdRemaining = std::max(0.0, placement.hold); + runtime.placement = std::move(placement); + /* + * The texture is left unallocated until the block is first drawn, for the reason the + * animated logos' are: a roll may carry a block the viewer does not reach for minutes, + * and a canvas-sized picture is not a small thing to hold VRAM for in the meantime. + */ + data->stickyBlocks.push_back(std::move(runtime)); + } + for (const StripTile &tile : strip.tiles) { const uint8_t *bits = tile.image.constBits(); gs_texture_t *texture = gs_texture_create(static_cast(tile.image.width()), @@ -548,6 +609,17 @@ void advance(CreditsSourceData *data, double seconds) } data->offset = travel; + + /* + * The strip has cleared the frame, but a sticky block may not have: one still + * holding at its anchor, or climbing off after being let go, is content that is + * on screen, and a roll is not over while something of it still is. The block + * is what ends the roll in that case -- see advanceStickyBlocks -- so the phase + * is left as it is and the roll simply stops moving. + */ + if (data->stickyPending) + break; + data->phase = Phase::Finished; finished = true; @@ -583,6 +655,155 @@ void advance(CreditsSourceData *data, double seconds) document.endingAction.execute(data->source); } +/* + * Ends the roll from outside `advance` -- which today means a sticky block whose hold has run out + * and whose release says that is the end of it. + * + * Everything the natural end does, in the same order: the phase, the ending action's countdown, + * the signal, and the action itself when there is no delay to wait through. Doing it here rather + * than teaching `advance` about blocks keeps the scroll loop about scrolling. + * + * Graphics thread only, and does nothing for a roll that has already finished or is looping. + */ +void finishRoll(CreditsSourceData *data) +{ + const Document &document = data->document; + if (document.loop) + return; + + bool fireAction = false; + { + std::lock_guard lock(data->stateMutex); + if (data->phase == Phase::Finished || data->phase == Phase::Idle) + return; + + data->phase = Phase::Finished; + + if (document.endingAction.type != EndingActionType::None) { + data->actionPending = true; + data->actionRemaining = document.endingAction.delay; + + if (document.endingAction.delay <= 0.0) { + data->actionPending = false; + fireAction = true; + } + } + } + + /* Outside the lock, so a handler can call straight back in. */ + emitCreditsFinished(data->source); + + if (fireAction) + document.endingAction.execute(data->source); +} + +/* + * Advances every sticky block by one tick, and reports whether any of them is still to have its + * say about the roll being over. + * + * A block travels with the roll until its slot reaches the anchor, then detaches and holds there + * while the rest of the roll scrolls on behind it. What happens when the hold runs out is the + * block's own setting: it stays where it is and ends the roll, it carries on up and off the top, + * or it does both. + * + * Where the block is *drawn* is not decided here -- see stickyBlockTop, which reads the state this + * leaves behind. That split is what lets a roll parked in manual scroll show its blocks pinned + * without any of the timing below running at all. + * + * Graphics thread only. + */ +void advanceStickyBlocks(CreditsSourceData *data, double seconds, bool rolling) +{ + const Document &document = data->document; + const int canvasHeight = std::max(1, document.height); + + double offset = 0.0; + uint64_t epoch = 0; + { + std::lock_guard lock(data->stateMutex); + offset = data->offset; + epoch = data->rollEpoch; + } + + /* The roll has gone as far as it goes; nothing below can still be on its way to an anchor. */ + const bool travelled = offset >= rollTravel(data) - 0.5; + const double stripTop = canvasHeight - offset; + + bool pending = false; + bool endNow = false; + + for (StickyBlockRuntime &runtime : data->stickyBlocks) { + const StickyBlockPlacement &placement = runtime.placement; + + /* A new pass of the roll is a new pass for its blocks: back to the top of the loop. */ + if (runtime.epoch != epoch) { + runtime.epoch = epoch; + runtime.state = StickyBlockRuntime::State::Waiting; + runtime.holdRemaining = std::max(0.0, placement.hold); + runtime.releasedTravel = 0.0; + runtime.spent = false; + } + + const double pinnedTop = placement.pinnedTop(canvasHeight); + + switch (runtime.state) { + case StickyBlockRuntime::State::Waiting: { + /* + * Pinned once the slot has carried it up to the anchor -- or once the roll has + * finished travelling, which catches a pin the roll would never otherwise reach + * (a negative offset on a short roll) rather than leaving the roll waiting on a + * block that can never arrive. + */ + const double naturalTop = stripTop + placement.rect.top(); + if (rolling && (naturalTop <= pinnedTop || travelled)) + runtime.state = StickyBlockRuntime::State::Pinned; + break; + } + + case StickyBlockRuntime::State::Pinned: + if (!rolling || placement.holdForever) + break; + + runtime.holdRemaining -= seconds; + if (runtime.holdRemaining > 0.0) + break; + + runtime.holdRemaining = 0.0; + if (stickyReleaseEndsAtHold(placement.release) && !runtime.spent) + endNow = true; + + runtime.spent = true; + if (stickyReleaseResumes(placement.release)) + runtime.state = StickyBlockRuntime::State::Released; + break; + + case StickyBlockRuntime::State::Released: + if (rolling) + runtime.releasedTravel += document.scrollSpeed * seconds; + break; + } + + /* + * What still has to happen before the roll is over. A block that ends the roll itself + * holds it open until it has; one that only leaves holds it open until it has left. + */ + if (!runtime.spent && stickyReleaseEndsAtHold(placement.release)) { + pending = true; + } else if (placement.release == StickyRelease::ResumeThenEnd) { + const double top = runtime.state == StickyBlockRuntime::State::Released + ? pinnedTop - runtime.releasedTravel + : std::max(pinnedTop, stripTop + placement.rect.top()); + if (top + placement.rect.height() > 0.0) + pending = true; + } + } + + data->stickyPending = pending; + + if (endNow) + finishRoll(data); +} + /* ------------------------------------------------------------------------- callbacks */ const char *getName(void *) @@ -894,6 +1115,11 @@ void videoTick(void *raw, float seconds) rolling = data->phase == Phase::Rolling && !data->paused; } + /* + * Before the animated logos and after the scroll, because a block's own timing is measured + * against the offset this tick just left behind. + */ + advanceStickyBlocks(data, delta, rolling); advanceAnimatedLogos(data, delta, rolling); } @@ -1010,6 +1236,55 @@ void drawAnimatedLogos(CreditsSourceData *data, gs_eparam_t *imageParam, double } } +/* + * Where a sticky block's top edge sits in canvas space this frame. + * + * Waiting and pinned are one expression rather than two branches: a block travels with the roll + * until the roll has carried it up to the anchor and stays there afterwards, which is exactly the + * lower of the two positions. Written that way so a roll parked in manual scroll -- where none of + * the timing in advanceStickyBlocks runs -- still shows every block where it belongs. + */ +double stickyBlockTop(const StickyBlockRuntime &runtime, double stripTop, int canvasHeight) +{ + const double pinnedTop = runtime.placement.pinnedTop(canvasHeight); + + if (runtime.state == StickyBlockRuntime::State::Released) + return pinnedTop - runtime.releasedTravel; + + return std::max(pinnedTop, stripTop + runtime.placement.rect.top()); +} + +/* + * Draws the sticky blocks over the strip. + * + * After the tiles and after the animated logos, because a pinned block is the thing the roll is + * running behind: it is on top of everything else by construction, which is what makes a closing + * card readable while the last of the credits goes past underneath it. + */ +void drawStickyBlocks(CreditsSourceData *data, gs_eparam_t *imageParam, double stripTop, int canvasHeight) +{ + for (StickyBlockRuntime &runtime : data->stickyBlocks) { + const StickyBlockPlacement &placement = runtime.placement; + + const double top = stickyBlockTop(runtime, stripTop, canvasHeight); + if (top >= canvasHeight || top + placement.rect.height() <= 0.0) + continue; + + if (!runtime.texture) + runtime.texture = createTexture(placement.image); + if (!runtime.texture) + continue; + + gs_effect_set_texture(imageParam, runtime.texture); + /* + * The picture reaches past the slot by its margin at each end -- the backdrop's padding + * and whatever the children paint outside their own boxes. + */ + drawClipped(0.0, top - placement.margin, static_cast(gs_texture_get_width(runtime.texture)), + static_cast(gs_texture_get_height(runtime.texture)), canvasHeight); + } +} + void videoRender(void *raw, gs_effect_t *) { auto *data = static_cast(raw); @@ -1056,6 +1331,7 @@ void videoRender(void *raw, gs_effect_t *) } drawAnimatedLogos(data, imageParam, stripTop, canvasHeight); + drawStickyBlocks(data, imageParam, stripTop, canvasHeight); } gs_blend_state_pop(); diff --git a/src/ui/DesignerDialog.cpp b/src/ui/DesignerDialog.cpp index 8557867..dec8bd2 100644 --- a/src/ui/DesignerDialog.cpp +++ b/src/ui/DesignerDialog.cpp @@ -386,9 +386,19 @@ DesignerDialog::DesignerDialog(obs_source_t *source, QWidget *parent) : QDialog( connect(action, &QAction::triggered, this, [this, type] { commitCurrentSection(); beginUndoStep(); - const int insertAt = currentIndex >= 0 ? currentIndex + 1 : document.sections.size(); - document.sections.insert(insertAt, Section::makeDefault(type)); - refreshSectionList(insertAt); + + /* + * Next to whatever is selected, in the same container: a section added while a + * child of a sticky block is selected joins that block, which is the only + * reading of "add one here" that does not surprise anyone. + */ + const SectionPath at = currentPath.isValid() + ? SectionPath{currentPath.parent, currentPath.index + 1} + : SectionPath{-1, static_cast(document.sections.size())}; + + const SectionPath added = insertSection(at, Section::makeDefault(type)); + refreshSectionList(-1); + refreshSectionList(rowOf(added)); schedulePreviewRefresh(); }); } @@ -694,26 +704,139 @@ void DesignerDialog::writeToSource() obs_source_update(source, updated); } +Section *DesignerDialog::sectionAt(const SectionPath &path) +{ + return const_cast
(std::as_const(*this).sectionAt(path)); +} + +const Section *DesignerDialog::sectionAt(const SectionPath &path) const +{ + if (path.index < 0) + return nullptr; + + if (path.parent < 0) + return path.index < document.sections.size() ? &document.sections.at(path.index) : nullptr; + + if (path.parent >= document.sections.size()) + return nullptr; + + const std::vector
&children = document.sections.at(path.parent).children; + return static_cast(path.index) < children.size() ? &children[static_cast(path.index)] + : nullptr; +} + +QVector DesignerDialog::pathsInOrder() const +{ + QVector paths; + paths.reserve(document.sections.size()); + + for (int index = 0; index < document.sections.size(); ++index) { + paths.append(SectionPath{-1, index}); + + const std::vector
&children = document.sections.at(index).children; + for (int child = 0; child < static_cast(children.size()); ++child) + paths.append(SectionPath{index, child}); + } + + return paths; +} + +int DesignerDialog::rowOf(const SectionPath &path) const +{ + for (int row = 0; row < rowPaths.size(); ++row) { + if (rowPaths.at(row).parent == path.parent && rowPaths.at(row).index == path.index) + return row; + } + return -1; +} + +DesignerDialog::SectionPath DesignerDialog::insertSection(const SectionPath &path, const Section §ion) +{ + /* + * A sticky block never goes inside another one -- the model says so and the loader enforces + * it -- so one dropped into a block lands after it instead of vanishing into it. + */ + if (path.parent >= 0 && section.type == SectionType::StickyBlock) { + const int after = std::min(path.parent + 1, static_cast(document.sections.size())); + document.sections.insert(after, section); + return SectionPath{-1, after}; + } + + if (path.parent < 0) { + const int at = std::clamp(path.index, 0, static_cast(document.sections.size())); + document.sections.insert(at, section); + return SectionPath{-1, at}; + } + + if (path.parent >= document.sections.size()) + return SectionPath{}; + + std::vector
&children = document.sections[path.parent].children; + const int at = std::clamp(path.index, 0, static_cast(children.size())); + children.insert(children.begin() + at, section); + return SectionPath{path.parent, at}; +} + +void DesignerDialog::removeSectionAt(const SectionPath &path) +{ + if (!sectionAt(path)) + return; + + if (path.parent < 0) { + document.sections.removeAt(path.index); + return; + } + + std::vector
&children = document.sections[path.parent].children; + children.erase(children.begin() + path.index); +} + +int DesignerDialog::highlightFor(const SectionPath &path) const +{ + /* + * The overlay and the preview know sections by their top-level index -- a child is drawn as + * part of its block's picture and has no box of its own out there -- so selecting a child + * highlights the block it belongs to. + */ + return path.parent >= 0 ? path.parent : path.index; +} + void DesignerDialog::refreshSectionList(int selectRow) { const QSignalBlocker blocker(sectionList); sectionList->clear(); - for (const Section §ion : document.sections) { - auto *item = new QListWidgetItem(section.displayLabel(), sectionList); - item->setToolTip(QString::fromUtf8(sectionTypeName(section.type))); - if (!section.visible) + rowPaths = pathsInOrder(); + + for (const SectionPath &path : rowPaths) { + const Section *section = sectionAt(path); + if (!section) + continue; + + /* + * Children are indented by their label rather than by a delegate: a list is the right + * widget for a roll that is a run of sections with one shallow exception in it, and a + * tree would trade the drag-and-drop reordering that works for one that has to be + * taught which drops mean what. + */ + const QString label = path.parent >= 0 ? QStringLiteral(" ") + section->displayLabel() + : section->displayLabel(); + + auto *item = new QListWidgetItem(label, sectionList); + item->setToolTip(QString::fromUtf8(sectionTypeName(section->type))); + if (!section->visible) item->setForeground(Qt::gray); } - const int row = std::clamp(selectRow, -1, static_cast(document.sections.size()) - 1); + const int row = std::clamp(selectRow, -1, static_cast(rowPaths.size()) - 1); sectionList->setCurrentRow(row); - currentIndex = row; + currentRow = row; + currentPath = row >= 0 ? rowPaths.at(row) : SectionPath{}; /* Set here as well as on selection: this path moves the row with the list's signals blocked. */ - preview->setHighlightedSection(row); + preview->setHighlightedSection(row >= 0 ? highlightFor(currentPath) : -1); - if (row >= 0) - editor->setSection(document.sections.at(row)); + if (const Section *section = sectionAt(currentPath)) + editor->setSection(*section); editorScroll->setEnabled(row >= 0); } @@ -721,7 +844,7 @@ void DesignerDialog::refreshSectionList(int selectRow) DesignerDialog::DocumentSnapshot DesignerDialog::snapshot() const { return DocumentSnapshot{document.sections, document.stylePresets, document.bundleFonts, - document.bundledFonts, document.fontSubstitutions, currentIndex}; + document.bundledFonts, document.fontSubstitutions, currentRow}; } void DesignerDialog::beginUndoStep() @@ -762,9 +885,11 @@ void DesignerDialog::restore(const DocumentSnapshot &state) document.fontSubstitutions = state.fontSubstitutions; /* Cleared first so refreshSectionList cannot write the editor back into a stale row. */ - currentIndex = -1; + currentPath = SectionPath{}; + currentRow = -1; editor->setPresets(document.stylePresets); - refreshSectionList(std::min(state.currentIndex, static_cast(document.sections.size()) - 1)); + refreshSectionList(-1); + refreshSectionList(std::min(state.currentIndex, static_cast(rowPaths.size()) - 1)); refreshPreview(); } @@ -839,20 +964,30 @@ void DesignerDialog::onSelectionChanged() editBurstTimer->stop(); const int row = sectionList->currentRow(); - currentIndex = row; + currentRow = row; + currentPath = row >= 0 && row < rowPaths.size() ? rowPaths.at(row) : SectionPath{}; editorScroll->setEnabled(row >= 0); - preview->setHighlightedSection(row); + preview->setHighlightedSection(currentPath.isValid() ? highlightFor(currentPath) : -1); - if (row >= 0 && row < document.sections.size()) - editor->setSection(document.sections.at(row)); + if (const Section *section = sectionAt(currentPath)) + editor->setSection(*section); } void DesignerDialog::commitCurrentSection() { - if (currentIndex < 0 || currentIndex >= document.sections.size()) + Section *section = sectionAt(currentPath); + if (!section) return; - document.sections[currentIndex] = editor->section(); + /* + * The editor knows nothing about children -- a sticky block's contents are edited as their + * own rows -- so what it hands back carries none, and the block's own are put back over the + * top of it. Writing the editor's copy through unchanged would empty the block on the first + * keystroke made anywhere on its form. + */ + std::vector
children = std::move(section->children); + *section = editor->section(); + section->children = std::move(children); } void DesignerDialog::onSectionEdited() @@ -860,12 +995,13 @@ void DesignerDialog::onSectionEdited() beginEditUndoStep(); commitCurrentSection(); - if (currentIndex >= 0 && currentIndex < sectionList->count()) { + const Section *section = sectionAt(currentPath); + if (section && currentRow >= 0 && currentRow < sectionList->count()) { const QSignalBlocker blocker(sectionList); - const Section §ion = document.sections.at(currentIndex); - QListWidgetItem *item = sectionList->item(currentIndex); - item->setText(section.displayLabel()); - item->setForeground(section.visible ? QBrush() : QBrush(Qt::gray)); + QListWidgetItem *item = sectionList->item(currentRow); + item->setText(currentPath.parent >= 0 ? QStringLiteral(" ") + section->displayLabel() + : section->displayLabel()); + item->setForeground(section->visible ? QBrush() : QBrush(Qt::gray)); } schedulePreviewRefresh(); @@ -874,28 +1010,71 @@ void DesignerDialog::onSectionEdited() void DesignerDialog::duplicateSection() { commitCurrentSection(); - if (currentIndex < 0 || currentIndex >= document.sections.size()) + + const Section *section = sectionAt(currentPath); + if (!section) return; beginUndoStep(); - document.sections.insert(currentIndex + 1, document.sections.at(currentIndex)); - refreshSectionList(currentIndex + 1); + /* Copied first: inserting into the container can move the section the pointer names. */ + const Section copy = *sectionAt(currentPath); + const SectionPath added = insertSection(SectionPath{currentPath.parent, currentPath.index + 1}, copy); + refreshSectionList(-1); + refreshSectionList(rowOf(added)); schedulePreviewRefresh(); } +bool DesignerDialog::confirmRemoveSection(const Section §ion) +{ + if (!askBeforeRemovingSection) + return true; + + QMessageBox box(this); + box.setWindowTitle(moduleText("Designer.RemoveSection.Title")); + /* + * Named rather than merely counted, because the section list is a column of short labels and + * "the selected one" is exactly what a misclick makes uncertain. + */ + box.setText(moduleText("Designer.RemoveSection.Confirm").arg(section.displayLabel())); + box.setIcon(QMessageBox::Question); + box.setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + box.setDefaultButton(QMessageBox::Cancel); + + /* + * Remembered for this window rather than for the machine: undo already covers a delete, so + * the box is a courtesy rather than the safety net, and someone who has switched it off + * gets it back by opening the designer again rather than by hunting for a setting. + */ + auto *remember = new QCheckBox(moduleText("Designer.RemoveSection.DontAsk"), &box); + box.setCheckBox(remember); + + const bool confirmed = box.exec() == QMessageBox::Yes; + if (confirmed && remember->isChecked()) + askBeforeRemovingSection = false; + + return confirmed; +} + void DesignerDialog::removeSection() { - if (currentIndex < 0 || currentIndex >= document.sections.size()) + const Section *section = sectionAt(currentPath); + if (!section) + return; + + if (!confirmRemoveSection(*section)) return; beginUndoStep(); - const int removed = currentIndex; + const int removedRow = currentRow; /* Drop the stale selection first so the editor cannot write back into the gap. */ - currentIndex = -1; - document.sections.removeAt(removed); + const SectionPath removed = currentPath; + currentPath = SectionPath{}; + currentRow = -1; + removeSectionAt(removed); - refreshSectionList(std::min(removed, static_cast(document.sections.size()) - 1)); + refreshSectionList(-1); + refreshSectionList(std::min(removedRow, static_cast(rowPaths.size()) - 1)); schedulePreviewRefresh(); } @@ -903,13 +1082,28 @@ void DesignerDialog::moveSection(int delta) { commitCurrentSection(); - const int target = currentIndex + delta; - if (currentIndex < 0 || target < 0 || target >= document.sections.size()) + /* + * Within the section's own container. The buttons are for ordering a run, and a section that + * hopped into or out of a sticky block on its way up the list would be doing something the + * arrow it was clicked on never said it would; dragging is what moves a section between + * containers, where the drop says plainly where it landed. + */ + const SectionPath target{currentPath.parent, currentPath.index + delta}; + if (!sectionAt(currentPath) || !sectionAt(target)) return; beginUndoStep(); - document.sections.swapItemsAt(currentIndex, target); - refreshSectionList(target); + + if (currentPath.parent < 0) { + document.sections.swapItemsAt(currentPath.index, target.index); + } else { + std::vector
&children = document.sections[currentPath.parent].children; + std::swap(children[static_cast(currentPath.index)], + children[static_cast(target.index)]); + } + + refreshSectionList(-1); + refreshSectionList(rowOf(target)); schedulePreviewRefresh(); } @@ -917,13 +1111,47 @@ void DesignerDialog::moveSectionTo(int from, int to) { commitCurrentSection(); - const int count = document.sections.size(); + const int count = rowPaths.size(); if (from < 0 || from >= count || to < 0 || to >= count || from == to) return; + const SectionPath fromPath = rowPaths.at(from); + const Section *moved = sectionAt(fromPath); + if (!moved) + return; + + const Section section = *moved; + beginUndoStep(); - document.sections.move(from, to); - refreshSectionList(to); + removeSectionAt(fromPath); + + /* + * Where it lands is decided by the row it now follows, which is the rule the list is already + * showing: drop something under a sticky block, or under one of that block's own rows, and it + * joins the block; drop it under anything else and it is top-level. Reading the drop off what + * precedes it is what lets one drag both put a section into a block and take it back out, + * without a second gesture to learn. + */ + const QVector after = pathsInOrder(); + const int predecessorRow = std::min(to, static_cast(after.size())) - 1; + + SectionPath at{-1, 0}; + if (predecessorRow >= 0 && predecessorRow < after.size()) { + const SectionPath &before = after.at(predecessorRow); + const Section *precedes = sectionAt(before); + + if (before.parent >= 0) { + at = SectionPath{before.parent, before.index + 1}; + } else if (precedes && precedes->type == SectionType::StickyBlock) { + at = SectionPath{before.index, 0}; + } else { + at = SectionPath{-1, before.index + 1}; + } + } + + const SectionPath landed = insertSection(at, section); + refreshSectionList(-1); + refreshSectionList(rowOf(landed)); schedulePreviewRefresh(); } @@ -1035,7 +1263,8 @@ void DesignerDialog::importJson() /* Only the content is taken; canvas and playback settings stay as configured here. */ document.sections = loaded.sections; document.stylePresets = loaded.stylePresets; - currentIndex = -1; + currentPath = SectionPath{}; + currentRow = -1; editor->setPresets(document.stylePresets); refreshSectionList(document.sections.isEmpty() ? -1 : 0); refreshPreview(); @@ -1103,7 +1332,7 @@ void DesignerDialog::applyPreview(const Document &rendered, const Strip &strip, preview->setStrip(strip, rendered.width, rendered.height, rendered.background); preview->setLayoutBoxes(boxes); - preview->setHighlightedSection(currentIndex); + preview->setHighlightedSection(currentPath.isValid() ? highlightFor(currentPath) : -1); const double travel = rendered.height + strip.height; const double seconds = rendered.scrollSpeed > 0.0 ? travel / rendered.scrollSpeed : 0.0; diff --git a/src/ui/DesignerDialog.hpp b/src/ui/DesignerDialog.hpp index bc22520..08d4f31 100644 --- a/src/ui/DesignerDialog.hpp +++ b/src/ui/DesignerDialog.hpp @@ -136,6 +136,11 @@ class DesignerDialog : public QDialog { void duplicateSection(); void removeSection(); + /* + * Asks before a section goes, unless this window has been told not to. True when the delete + * should go ahead. + */ + bool confirmRemoveSection(const Section §ion); void moveSection(int delta); void moveSectionTo(int from, int to); @@ -190,7 +195,7 @@ class DesignerDialog : public QDialog { */ void applyPreview(const Document &rendered, const Strip &strip, const LayoutBoxes &boxes); - /* Commits the editor's current state into `document` at `currentIndex`. */ + /* Commits the editor's current state into `document` at `currentPath`. */ void commitCurrentSection(); /* @@ -254,7 +259,46 @@ class DesignerDialog : public QDialog { OBSWeakSource weakSource; Document document; - int currentIndex = -1; + + /* + * Where a section lives, now that the list is not flat: a sticky block holds sections of its + * own and shows them indented under it. + * + * A path rather than an index because the row a user has selected may be a child, and every + * operation on it -- edit, duplicate, delete, move -- has to act on the container it really + * belongs to rather than on the top-level list it merely appears in. + */ + struct SectionPath { + /* Index of the sticky block holding it, or -1 when the section is top-level. */ + int parent = -1; + int index = -1; + + bool isValid() const { return index >= 0; } + }; + + /* The section a path names, or null when the path does not resolve. */ + Section *sectionAt(const SectionPath &path); + const Section *sectionAt(const SectionPath &path) const; + + /* The path each row of the list stands for, in visual order. */ + QVector pathsInOrder() const; + + /* The row a path is drawn on, or -1 when it is not on screen. */ + int rowOf(const SectionPath &path) const; + + /* Inserts `section` into the container `path` names, at `path.index`. Returns where it went. */ + SectionPath insertSection(const SectionPath &path, const Section §ion); + + /* Takes the section out of its container. */ + void removeSectionAt(const SectionPath &path); + + /* Which section the preview should highlight for a path: a child highlights its own block. */ + int highlightFor(const SectionPath &path) const; + + int currentRow = -1; + SectionPath currentPath; + /* Rebuilt by refreshSectionList, so a row can be turned back into a path. */ + QVector rowPaths; /* * Private to this dialog: the source keeps its own cache. Shared ownership because a @@ -323,6 +367,12 @@ class DesignerDialog : public QDialog { * with the window: the question is about a train of edits, not about a document. */ QHash linkedEditChoices; + /* + * Whether deleting a section still asks first. Per window, like the answers above: undo + * already covers a delete, so this is a courtesy rather than the safety net, and a window + * opened afresh asks again rather than leaving a setting switched off somewhere unfindable. + */ + bool askBeforeRemovingSection = true; }; } // namespace closingtime diff --git a/src/ui/PreviewWidget.cpp b/src/ui/PreviewWidget.cpp index c829f87..e228694 100644 --- a/src/ui/PreviewWidget.cpp +++ b/src/ui/PreviewWidget.cpp @@ -269,6 +269,7 @@ void PreviewWidget::paintEvent(QPaintEvent *) painter.drawImage(QRectF(canvasColumn.left(), top, canvasColumn.width(), tileHeight), tile.image); } + paintStickyBlocks(painter, canvasColumn, scale); paintAnimatedLogos(painter, canvasColumn, scale); const qreal framedHeight = onAirHeight(); @@ -310,6 +311,33 @@ void PreviewWidget::paintEvent(QPaintEvent *) * contents over the top -- so a text column sitting exactly on its section's edge is still * visible. */ +/* + * Draws each sticky block back into the slot the strip left empty for it. + * + * The pane is the roll end to end rather than a canvas playing, so a block is shown where it sits + * in the running order -- which is what makes the section list, the preview and the scroll bar + * agree about where the block is. Where it *pins* is a property of playback, and playback is what + * the OBS canvas shows; drawing the block halfway up the pane instead would leave a hole in the + * roll and a card floating over a part of it that has nothing to do with either. + */ +void PreviewWidget::paintStickyBlocks(QPainter &painter, const QRect &canvasColumn, qreal scale) const +{ + for (const StickyBlockPlacement &placement : strip.stickyBlocks) { + if (placement.image.isNull()) + continue; + + /* The picture reaches past the slot by its margin at each end; so does what is drawn. */ + const qreal top = (placement.rect.top() - placement.margin) * scale - scroll; + const qreal blockHeight = placement.image.height() * scale; + + if (top + blockHeight < 0 || top > height()) + continue; + + painter.drawImage(QRectF(canvasColumn.left(), top, canvasColumn.width(), blockHeight), + placement.image); + } +} + void PreviewWidget::paintAnimatedLogos(QPainter &painter, const QRect &canvasColumn, qreal scale) const { if (strip.animatedLogos.isEmpty()) diff --git a/src/ui/PreviewWidget.hpp b/src/ui/PreviewWidget.hpp index 8acbf35..52bf38e 100644 --- a/src/ui/PreviewWidget.hpp +++ b/src/ui/PreviewWidget.hpp @@ -92,7 +92,12 @@ class PreviewWidget : public QWidget { * Draws the animated logos into the holes the strip left for them. Always called, playing or * not: a hole with nothing drawn into it is a missing logo, not a paused one. */ - void paintAnimatedLogos(QPainter &painter, const QRect &canvasColumn, qreal scale) const; +/* + * Draws the sticky blocks into the slots the strip left for them, so the preview shows the + * roll as it is written rather than as it will be pinned. + */ + void paintStickyBlocks(QPainter &painter, const QRect &canvasColumn, qreal scale) const; + void paintAnimatedLogos(QPainter &painter, const QRect &canvasColumn, qreal scale) const; Strip strip; LayoutBoxes layoutBoxes; diff --git a/src/ui/SectionEditor.cpp b/src/ui/SectionEditor.cpp index b6fc4c5..0af7525 100644 --- a/src/ui/SectionEditor.cpp +++ b/src/ui/SectionEditor.cpp @@ -874,6 +874,75 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) subtitleOrder->setToolTip(moduleText("Designer.SubtitleOrder.Tip")); form->addRow(moduleText("Designer.SubtitleOrder"), subtitleOrder); + /* + * The sticky block's own rows. The pin is a pair of points -- one on the block, one down the + * canvas -- because "the middle of the block, halfway down the frame" needs both halves and a + * single number can only ever say one of them. + */ + stickyAnchor = new QComboBox(this); + stickyAnchor->addItem(moduleText("Designer.StickyAnchor.Top"), static_cast(StickyAnchor::Top)); + stickyAnchor->addItem(moduleText("Designer.StickyAnchor.Center"), static_cast(StickyAnchor::Center)); + stickyAnchor->addItem(moduleText("Designer.StickyAnchor.Bottom"), static_cast(StickyAnchor::Bottom)); + stickyAnchor->setToolTip(moduleText("Designer.StickyAnchor.Tip")); + form->addRow(moduleText("Designer.StickyAnchor"), stickyAnchor); + + stickyCanvasPosition = new QSpinBox(this); + stickyCanvasPosition->setRange(0, 100); + stickyCanvasPosition->setSuffix(QStringLiteral(" %")); + stickyCanvasPosition->setToolTip(moduleText("Designer.StickyCanvasPosition.Tip")); + form->addRow(moduleText("Designer.StickyCanvasPosition"), stickyCanvasPosition); + + stickyOffset = new QSpinBox(this); + stickyOffset->setRange(-4096, 4096); + stickyOffset->setSuffix(QStringLiteral(" px")); + stickyOffset->setToolTip(moduleText("Designer.StickyOffset.Tip")); + form->addRow(moduleText("Designer.StickyOffset"), stickyOffset); + + stickyHold = new QDoubleSpinBox(this); + stickyHold->setRange(0.0, 3600.0); + stickyHold->setDecimals(1); + stickyHold->setSingleStep(0.5); + stickyHold->setSuffix(moduleText("Designer.Seconds")); + stickyHold->setToolTip(moduleText("Designer.StickyHold.Tip")); + form->addRow(moduleText("Designer.StickyHold"), stickyHold); + + stickyHoldForever = new QCheckBox(moduleText("Designer.StickyHoldForever"), this); + stickyHoldForever->setToolTip(moduleText("Designer.StickyHoldForever.Tip")); + form->addRow(QString(), stickyHoldForever); + + stickyRelease = new QComboBox(this); + stickyRelease->addItem(moduleText("Designer.StickyRelease.EndAtHold"), + static_cast(StickyRelease::EndAtHold)); + stickyRelease->addItem(moduleText("Designer.StickyRelease.ResumeThenEnd"), + static_cast(StickyRelease::ResumeThenEnd)); + stickyRelease->addItem(moduleText("Designer.StickyRelease.ResumeEndAtHold"), + static_cast(StickyRelease::ResumeEndAtHold)); + stickyRelease->setToolTip(moduleText("Designer.StickyRelease.Tip")); + form->addRow(moduleText("Designer.StickyRelease"), stickyRelease); + + /* + * Said on the form rather than in a tooltip, because a block that holds for ever and is set + * to end the roll at its hold is a roll with no end -- which is a perfectly reasonable thing + * to build on purpose and a baffling thing to meet by accident. + */ + stickyForeverWarning = new QLabel(moduleText("Designer.StickyHoldForever.Warning"), this); + stickyForeverWarning->setWordWrap(true); + form->addRow(QString(), stickyForeverWarning); + + stickyBackdrop = new QCheckBox(moduleText("Designer.StickyBackdrop"), this); + stickyBackdrop->setToolTip(moduleText("Designer.StickyBackdrop.Tip")); + form->addRow(QString(), stickyBackdrop); + + stickyBackdropColour = new ColourButton(this); + stickyBackdropColour->setDialogTitle(moduleText("Designer.StickyBackdropColor")); + form->addRow(moduleText("Designer.StickyBackdropColor"), stickyBackdropColour); + + stickyBackdropPadding = new QSpinBox(this); + stickyBackdropPadding->setRange(0, 2048); + stickyBackdropPadding->setSuffix(QStringLiteral(" px")); + stickyBackdropPadding->setToolTip(moduleText("Designer.StickyBackdropPadding.Tip")); + form->addRow(moduleText("Designer.StickyBackdropPadding"), stickyBackdropPadding); + spacerHeight = new QSpinBox(this); spacerHeight->setRange(0, 20000); spacerHeight->setSuffix(QStringLiteral(" px")); @@ -1116,6 +1185,22 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) connect(bridgeOffset, &QSpinBox::valueChanged, this, notify); connect(bridgeGap, &QSpinBox::valueChanged, this, notify); connect(bridgeMinGap, &QSpinBox::valueChanged, this, notify); + connect(stickyAnchor, &QComboBox::currentIndexChanged, this, notify); + connect(stickyCanvasPosition, &QSpinBox::valueChanged, this, notify); + connect(stickyOffset, &QSpinBox::valueChanged, this, notify); + connect(stickyHold, &QDoubleSpinBox::valueChanged, this, notify); + connect(stickyRelease, &QComboBox::currentIndexChanged, this, notify); + connect(stickyBackdropColour, &ColourButton::colourChanged, this, notify); + connect(stickyBackdropPadding, &QSpinBox::valueChanged, this, notify); + /* Both of these decide what else on the form applies, so they re-run the visibility pass. */ + for (QCheckBox *box : {stickyHoldForever, stickyBackdrop}) { + connect(box, &QCheckBox::toggled, this, [this] { + if (loading) + return; + applyTypeVisibility(static_cast(typeBox->currentData().toInt())); + emitChanged(); + }); + } connect(bridgeSplit, &QSpinBox::valueChanged, this, notify); connect(bridgeRowAlign, &QComboBox::currentIndexChanged, this, notify); connect(bridgeSpanEmpty, &QCheckBox::toggled, this, notify); @@ -1274,7 +1359,16 @@ void SectionEditor::setSection(const Section &source) entryGap->setValue(source.entryGap); subtitleGap->setValue(source.subtitleGap); selectByData(subtitleOrder, source.subtitleFirst ? 1 : 0); - spacerHeight->setValue(source.spacerHeight); + spacerHeight->setValue(source.spacerHeight); selectByData(stickyAnchor, static_cast(source.stickyAnchor)); + stickyCanvasPosition->setValue(qRound(source.stickyCanvasPosition * 100.0)); + stickyOffset->setValue(qRound(source.stickyOffset)); + stickyHold->setValue(source.stickyHold); + stickyHoldForever->setChecked(source.stickyHoldForever); + selectByData(stickyRelease, static_cast(source.stickyRelease)); + stickyBackdrop->setChecked(source.stickyBackdrop); + stickyBackdropColour->setColour(source.stickyBackdropColor); + stickyBackdropPadding->setValue(qRound(source.stickyBackdropPadding)); + paddingTop->setValue(source.paddingTop); paddingBottom->setValue(source.paddingBottom); marginX->setValue(source.marginX); @@ -1366,6 +1460,15 @@ Section SectionEditor::section() const result.subtitleGap = subtitleGap->value(); result.subtitleFirst = subtitleOrder->currentData().toInt() == 1; result.spacerHeight = spacerHeight->value(); + result.stickyAnchor = static_cast(stickyAnchor->currentData().toInt()); + result.stickyCanvasPosition = stickyCanvasPosition->value() / 100.0; + result.stickyOffset = stickyOffset->value(); + result.stickyHold = stickyHold->value(); + result.stickyHoldForever = stickyHoldForever->isChecked(); + result.stickyRelease = static_cast(stickyRelease->currentData().toInt()); + result.stickyBackdrop = stickyBackdrop->isChecked(); + result.stickyBackdropColor = stickyBackdropColour->colour(); + result.stickyBackdropPadding = stickyBackdropPadding->value(); result.paddingTop = paddingTop->value(); result.paddingBottom = paddingBottom->value(); result.marginX = marginX->value(); @@ -1552,6 +1655,27 @@ void SectionEditor::applyTypeVisibility(SectionType type) form->setRowVisible(subtitleOrder, stacksSubtitles); form->setRowVisible(spacerHeight, type == SectionType::Spacer); + /* + * The sticky block's rows. The hold is hidden outright while the block holds for ever, since + * a number of seconds that nothing counts down is a control that lies; the warning underneath + * appears only for the pairing that really has no end to it. + */ + const bool sticky = type == SectionType::StickyBlock; + const auto release = static_cast(stickyRelease->currentData().toInt()); + const bool backdrop = sticky && stickyBackdrop->isChecked(); + + form->setRowVisible(stickyAnchor, sticky); + form->setRowVisible(stickyCanvasPosition, sticky); + form->setRowVisible(stickyOffset, sticky); + form->setRowVisible(stickyHold, sticky && !stickyHoldForever->isChecked()); + form->setRowVisible(stickyHoldForever, sticky); + form->setRowVisible(stickyRelease, sticky); + form->setRowVisible(stickyForeverWarning, + sticky && stickyHoldForever->isChecked() && stickyReleaseEndsAtHold(release)); + form->setRowVisible(stickyBackdrop, sticky); + form->setRowVisible(stickyBackdropColour, backdrop); + form->setRowVisible(stickyBackdropPadding, backdrop); + /* * A divider has a style even though it carries no section text: the artwork is inked from * it, and a word or a mark in the centre stack is drawn with it. diff --git a/src/ui/SectionEditor.hpp b/src/ui/SectionEditor.hpp index 670f59a..ce5101e 100644 --- a/src/ui/SectionEditor.hpp +++ b/src/ui/SectionEditor.hpp @@ -29,6 +29,7 @@ class QComboBox; class QDoubleSpinBox; class QFormLayout; class QGroupBox; +class QLabel; class QLineEdit; class QPlainTextEdit; class QPushButton; @@ -333,6 +334,18 @@ class SectionEditor : public QWidget { QComboBox *sectionAlign = nullptr; QSpinBox *spacerHeight = nullptr; + /* Sticky Ending Block sections only. */ + QComboBox *stickyAnchor = nullptr; + QSpinBox *stickyCanvasPosition = nullptr; + QSpinBox *stickyOffset = nullptr; + QDoubleSpinBox *stickyHold = nullptr; + QCheckBox *stickyHoldForever = nullptr; + QComboBox *stickyRelease = nullptr; + QLabel *stickyForeverWarning = nullptr; + QCheckBox *stickyBackdrop = nullptr; + ColourButton *stickyBackdropColour = nullptr; + QSpinBox *stickyBackdropPadding = nullptr; + StyleEditor *primaryStyle = nullptr; QGroupBox *secondaryGroup = nullptr; StyleEditor *secondaryStyle = nullptr; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 76383a0..7d8e0e0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,6 +21,7 @@ target_sources( suites/Layout.cpp suites/LogoRows.cpp suites/Persistence.cpp + suites/StickyBlocks.cpp suites/StyleLibrary.cpp suites/SectionTypes.cpp suites/SubtitleHeadings.cpp diff --git a/tests/suites/Layout.cpp b/tests/suites/Layout.cpp index d03db8d..5d3ef2b 100644 --- a/tests/suites/Layout.cpp +++ b/tests/suites/Layout.cpp @@ -42,10 +42,18 @@ CT_SUITE(layout_every_type, "Measure/render agreement and containment, over ever checkEq(boxesOutsideContent(document), 0, "nothing is placed outside the content area of its own section"); - /* Every section reports its own box, so the designer's overlay can find it. */ - checkEq(boxesOf(document, LayoutBox::Kind::Section).size(), document.sections.size(), - "every section reports a box"); - checkEq(boxesOf(document, LayoutBox::Kind::Content).size(), document.sections.size(), + /* + * Every section reports its own box, so the designer's overlay can find it -- the children of + * a sticky block included, since those are sections too and are laid out by the same call. + */ + int sectionCount = 0; + visitSections(document.sections, [§ionCount](const Section §ion) { + if (section.visible) + ++sectionCount; + }); + + checkEq(boxesOf(document, LayoutBox::Kind::Section).size(), sectionCount, "every section reports a box"); + checkEq(boxesOf(document, LayoutBox::Kind::Content).size(), sectionCount, "every section reports a content area"); saveArtifact(QStringLiteral("layout-every-type"), document); @@ -91,7 +99,18 @@ CT_SUITE(layout_padding, "Padding and the section box, over every section type") const Document narrowDocument = documentWith(narrow); checkEq(boxesOutsideContent(narrowDocument), 0, "a narrowed section stays inside its own box"); + /* + * A sticky block is the one type the box does not narrow: it holds whole sections, each + * with a box of its own, so it spans the canvas and lets them place themselves. Asserted + * rather than skipped, because "this type ignores the setting" is exactly the kind of + * thing that should fail here if it ever stops being true quietly. + */ const QRectF box = boxOf(narrowDocument, LayoutBox::Kind::Section); + if (type == SectionType::StickyBlock) { + checkNear(box.width(), document.width, 1.0, "a sticky block spans the canvas"); + continue; + } + checkNear(box.width(), document.width / 2.0, 1.0, "the section box is the share it was given"); checkNear(box.left(), 0.0, 1.0, "a left-aligned box sits against the left edge"); } diff --git a/tests/suites/Persistence.cpp b/tests/suites/Persistence.cpp index 4aa2438..03d3e4e 100644 --- a/tests/suites/Persistence.cpp +++ b/tests/suites/Persistence.cpp @@ -91,6 +91,16 @@ Section distinctive(SectionType type) section.sectionWidth = 0.75; section.sectionAlign = HAlign::Right; section.spacerHeight = 313; + section.stickyAnchor = StickyAnchor::Bottom; + section.stickyCanvasPosition = 0.2; + section.stickyOffset = -18.0; + section.stickyHold = 12.5; + section.stickyHoldForever = true; + section.stickyRelease = StickyRelease::ResumeEndAtHold; + section.stickyBackdrop = true; + section.stickyBackdropColor = QColor(12, 34, 56, 210); + section.stickyBackdropPadding = 37.0; + section.children.push_back(Section::makeDefault(SectionType::Header)); section.visible = false; section.entries = {Entry{QStringLiteral("one"), QStringLiteral("two"), QStringLiteral("three"), QStringLiteral("four"), LogoRef{QStringLiteral("/e.png"), 61}}}; @@ -167,6 +177,20 @@ void compare(const Section &loaded, const Section &original) checkNear(loaded.sectionWidth, original.sectionWidth, 0.001, "sectionWidth"); check(loaded.sectionAlign == original.sectionAlign, "sectionAlign"); checkEq(loaded.spacerHeight, original.spacerHeight, "spacerHeight"); + check(loaded.stickyAnchor == original.stickyAnchor, "stickyAnchor"); + checkNear(loaded.stickyCanvasPosition, original.stickyCanvasPosition, 0.001, "stickyCanvasPosition"); + checkNear(loaded.stickyOffset, original.stickyOffset, 0.001, "stickyOffset"); + checkNear(loaded.stickyHold, original.stickyHold, 0.001, "stickyHold"); + check(loaded.stickyHoldForever == original.stickyHoldForever, "stickyHoldForever"); + check(loaded.stickyRelease == original.stickyRelease, "stickyRelease"); + check(loaded.stickyBackdrop == original.stickyBackdrop, "stickyBackdrop"); + check(loaded.stickyBackdropColor == original.stickyBackdropColor, "stickyBackdropColor"); + checkNear(loaded.stickyBackdropPadding, original.stickyBackdropPadding, 0.001, "stickyBackdropPadding"); + checkEq(static_cast(loaded.children.size()), static_cast(original.children.size()), "child count"); + if (!loaded.children.empty() && !original.children.empty()) { + check(loaded.children.front().type == original.children.front().type, "a child keeps its type"); + checkEq(loaded.children.front().text, original.children.front().text, "and its content"); + } check(loaded.visible == original.visible, "visible"); checkEq(loaded.entries.size(), original.entries.size(), "entry count"); @@ -250,6 +274,11 @@ CT_SUITE(persistence_legacy, "Documents written before a field existed, and stor check(legacy.dividerCap.isEmpty(), "an absent divider cap is an end with nothing on it"); check(legacy.dividerArm == DividerShape::Rule, "an absent divider arm is the plain rule, not None"); check(legacy.dividerMirrorEnds, "divider ends are mirrored by default"); + check(legacy.children.empty(), "a section from before sticky blocks holds nothing"); + check(legacy.stickyAnchor == StickyAnchor::Center, "a sticky block pins by its middle unless told otherwise"); + checkNear(legacy.stickyCanvasPosition, 0.5, 0.001, "halfway down the frame"); + checkNear(legacy.stickyHold, 5.0, 0.001, "and holds for a measured time rather than none"); + check(legacy.stickyRelease == StickyRelease::EndAtHold, "with the hold being the end of the roll"); checkEq(legacy.dividerRules, 1, "a divider has at least one rule"); checkNear(legacy.sectionWidth, 1.0, 0.001, "sectionWidth falls back to the full canvas"); diff --git a/tests/suites/SectionTypes.cpp b/tests/suites/SectionTypes.cpp index c8da6dc..4e684f1 100644 --- a/tests/suites/SectionTypes.cpp +++ b/tests/suites/SectionTypes.cpp @@ -89,6 +89,7 @@ CT_SUITE(section_types, "The type table, its ids and its predicates") {SectionType::MultiLogoList, "multi_logo_list"}, {SectionType::SectionDivider, "section_divider"}, {SectionType::Spacer, "spacer"}, + {SectionType::StickyBlock, "sticky_block"}, }; checkEq(types.size(), contractual.size(), "every type is listed here, so a new one has to be added"); diff --git a/tests/suites/StickyBlocks.cpp b/tests/suites/StickyBlocks.cpp new file mode 100644 index 0000000..379615a --- /dev/null +++ b/tests/suites/StickyBlocks.cpp @@ -0,0 +1,263 @@ +/* +Closing Time +Copyright (C) 2026 Voidscape Development + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include + +#include "harness/Fixtures.hpp" +#include "harness/Harness.hpp" +#include "harness/Probe.hpp" + +using namespace closingtime; +using namespace closingtime::test; + +namespace { + +/* A block holding one heading, with no padding of its own to account for. */ +Section stickyBlock(const QString &text = QStringLiteral("The End")) +{ + Section block = unpadded(SectionType::StickyBlock); + block.children.clear(); + + Section title = unpadded(SectionType::Title); + title.text = text; + block.children.push_back(title); + + return block; +} + +/* The same heading on its own, for measuring the block against what it holds. */ +Section loneTitle(const QString &text = QStringLiteral("The End")) +{ + Section title = unpadded(SectionType::Title); + title.text = text; + return title; +} + +} // namespace + +CT_SUITE(sticky_slot, "A sticky block taking its place in the roll") +{ + /* + * The block is laid into the roll like any other section: it takes exactly the room its + * content would have taken inline, so nothing above or below it moves for the feature. + */ + const int blockHeight = measure(documentWith(stickyBlock())); + const int titleHeight = measure(documentWith(loneTitle())); + checkEq(blockHeight, titleHeight, "the block is as tall as what it holds"); + + Section padded = stickyBlock(); + padded.paddingTop = 30; + padded.paddingBottom = 20; + checkEq(measure(documentWith(padded)) - blockHeight, 50, "and its own padding is worth its pixels"); + + /* A hidden child takes nothing with it, exactly as a hidden section does at the top level. */ + Section hiddenChild = stickyBlock(); + hiddenChild.children.front().visible = false; + checkEq(measure(documentWith(hiddenChild)), 0, "a block whose every child is hidden takes no height"); + + Section hidden = stickyBlock(); + hidden.visible = false; + checkEq(measure(documentWith(hidden)), 0, "and a hidden block takes none either"); +} + +CT_SUITE(sticky_hole, "The strip leaving the block's slot empty") +{ + const Document document = documentWith(stickyBlock()); + const Strip strip = renderStrip(document); + + checkEq(strip.stickyBlocks.size(), 1, "the strip carries the block"); + if (strip.stickyBlocks.isEmpty()) + return; + + const StickyBlockPlacement &placement = strip.stickyBlocks.first(); + const QRectF slot = placement.rect; + + /* + * Nothing is painted into the slot. A block that stops scrolling while the roll carries on + * cannot be part of the picture that scrolls, so the strip has to come out empty where it + * would otherwise have drawn it -- otherwise the block would be on screen twice, once + * pinned and once travelling. + */ + check(inkOf(flatten(strip), slot).isEmpty(), "the strip draws nothing in the slot"); + + /* And the block itself is carried as a picture, with its own content really in it. */ + check(!placement.image.isNull(), "the block came back as a picture"); + checkEq(placement.image.width(), document.width, "as wide as the canvas"); + checkEq(placement.image.height(), qRound(slot.height()) + placement.margin * 2, + "and as tall as its slot plus the margin at each end"); + check(!inkOf(placement.image).isEmpty(), "with the block's own content drawn in it"); + + /* The slot is reported to the overlay, so the designer can say where the block sits. */ + const QRectF box = boxOf(document, LayoutBox::Kind::Sticky); + check(!box.isNull(), "the slot is reported as a layout box"); + checkNear(box.height(), slot.height(), 1.0, "over the height the block occupies"); +} + +CT_SUITE(sticky_anchor, "Where a pinned block lands on the canvas") +{ + Document document = documentWith(stickyBlock()); + document.height = 1080; + + const Strip strip = renderStrip(document); + if (strip.stickyBlocks.isEmpty()) { + fail("the strip carries no block"); + return; + } + + StickyBlockPlacement placement = strip.stickyBlocks.first(); + const double blockHeight = placement.rect.height(); + + /* + * The pin is a pair of points, so each anchor puts a different part of the block on the same + * place down the canvas. Halfway down, that is: the top edge lands at the halfway line, the + * middle straddles it, and the bottom edge ends on it. + */ + placement.canvasPosition = 0.5; + placement.offset = 0.0; + + placement.anchor = StickyAnchor::Top; + checkNear(placement.pinnedTop(document.height), 540.0, 0.001, "a top anchor starts at the line"); + + placement.anchor = StickyAnchor::Center; + checkNear(placement.pinnedTop(document.height), 540.0 - blockHeight / 2.0, 0.001, + "a centre anchor straddles it"); + + placement.anchor = StickyAnchor::Bottom; + checkNear(placement.pinnedTop(document.height), 540.0 - blockHeight, 0.001, "a bottom anchor ends on it"); + + /* The nudge is pixels on top of whatever the share worked out to. */ + placement.anchor = StickyAnchor::Top; + placement.offset = -40.0; + checkNear(placement.pinnedTop(document.height), 500.0, 0.001, "the nudge moves it by its own pixels"); + + /* And the share is of the canvas, so a taller canvas pins proportionally further down. */ + placement.offset = 0.0; + checkNear(placement.pinnedTop(2160), 1080.0, 0.001, "the share follows the canvas height"); +} + +CT_SUITE(sticky_backdrop, "The panel drawn behind a pinned block") +{ + Section plain = stickyBlock(); + + Section panelled = plain; + panelled.stickyBackdrop = true; + panelled.stickyBackdropColor = QColor(0, 0, 0, 255); + panelled.stickyBackdropPadding = 40.0; + + /* The panel is part of the picture rather than a quad behind it, and does not move the roll. */ + checkEq(measure(documentWith(panelled)), measure(documentWith(plain)), + "a backdrop changes nothing about the layout"); + + const Strip strip = renderStrip(documentWith(panelled)); + if (strip.stickyBlocks.isEmpty()) { + fail("the strip carries no block"); + return; + } + + const StickyBlockPlacement &placement = strip.stickyBlocks.first(); + check(placement.margin >= 40, "the picture is grown to hold the panel's padding"); + + const Ink ink = inkOf(placement.image); + checkEq(ink.left, 0, "the panel reaches the left edge of the canvas"); + checkEq(ink.right, placement.image.width() - 1, "and the right one"); + checkEq(ink.height(), placement.image.height(), "and fills the picture from top to bottom"); + + /* Without it, the picture inks only where the block's own content is. */ + const Strip bare = renderStrip(documentWith(plain)); + if (!bare.stickyBlocks.isEmpty()) + check(inkOf(bare.stickyBlocks.first().image).width() < placement.image.width(), + "and without a panel only the content is drawn"); +} + +CT_SUITE(sticky_children, "What a block may hold") +{ + /* + * A block holds whole sections, and they are laid out by the same call that lays out the + * roll -- which is what makes "anything except another block" a true statement rather than a + * list of the types somebody remembered to support. + */ + Section block = unpadded(SectionType::StickyBlock); + block.children.clear(); + + int expected = 0; + for (SectionType type : allSectionTypes()) { + if (type == SectionType::StickyBlock) + continue; + + Section child = Section::makeDefault(type); + if (sectionUsesLogos(type)) + withLogo(child); + + expected += measure(documentWith(child)); + block.children.push_back(child); + } + + checkEq(measure(documentWith(block)), expected, "a block is as tall as the sections it holds"); + checkEq(boxesOutsideContent(documentWith(block)), 0, "and nothing in it is placed outside its own box"); + + /* + * A block inside a block is dropped by the loader rather than loaded, because everything + * downstream of here is written for one level of pinning. + */ + Section nested = unpadded(SectionType::StickyBlock); + nested.children.clear(); + nested.children.push_back(unpadded(SectionType::StickyBlock)); + nested.children.push_back(loneTitle()); + + OBSDataAutoRelease data = obs_data_create(); + nested.save(data); + + Section loaded; + loaded.load(data); + + checkEq(static_cast(loaded.children.size()), 1, "a nested block is dropped on the way in"); + if (!loaded.children.empty()) + check(loaded.children.front().type == SectionType::Title, "and everything else survives"); +} + +CT_SUITE(sticky_roll, "A block among the sections of a whole roll") +{ + /* + * The point of the slot: what is above and below the block sits exactly where it would have + * without it, so turning a run of sections into a block does not reflow the roll around it. + */ + Section before = loneTitle(QStringLiteral("Cast")); + Section after = loneTitle(QStringLiteral("Crew")); + + const Document inline_ = documentWith({before, loneTitle(), after}); + const Document blocked = documentWith({before, stickyBlock(), after}); + + checkEq(measure(blocked), measure(inline_), "the roll is the same height either way"); + + const QVector inlineBoxes = boxesOf(inline_, LayoutBox::Kind::Section); + const QVector blockedBoxes = boxesOf(blocked, LayoutBox::Kind::Section); + if (inlineBoxes.size() == 3 && blockedBoxes.size() == 4) { + /* The block reports its own box and its child's, which is why there is one more. */ + checkNear(blockedBoxes.at(0).top(), inlineBoxes.at(0).top(), 0.5, "the section above has not moved"); + checkNear(blockedBoxes.at(3).top(), inlineBoxes.at(2).top(), 0.5, "nor the one below"); + } else { + fail(QStringLiteral("unexpected box counts: %1 inline, %2 blocked") + .arg(inlineBoxes.size()) + .arg(blockedBoxes.size())); + } + + /* Two blocks in one roll is an ordinary document, not a special case. */ + const Document two = documentWith({stickyBlock(QStringLiteral("Part One")), after, + stickyBlock(QStringLiteral("Part Two"))}); + checkEq(renderStrip(two).stickyBlocks.size(), 2, "each block is carried in its own right"); +} From 8f6b432509de3bceaca64502983c4f6d55afa6e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:46:42 +0000 Subject: [PATCH 4/7] Ask three easy questions where the section editor asked one hard one The editor was one column of some forty rows with a picker of nineteen types at the top of it. Everything was reachable and almost nothing was findable. The picker now offers seven base types and a few switches beside them: a heading is a Title, with a subtitle, with a logo -- three plain answers -- rather than "Title w/ Subtitle & Logo" chosen from a list of ten headings, and a list is a List that holds lines, pairs or images over so many columns rather than six types that differ in exactly those two ways. The document still carries all twenty types under their own ids: `decomposeSectionType` and `composeSectionType` are the mapping, they live in the model because taking a type apart is a property of the type table, and the harness asserts that every type survives the round trip. The rows below are gathered into named groups that fold away -- what the section says, how this kind of section is put together, where it sits -- with the middle one named after the type in it, since "Bridge settings" and "Divider settings" are never both on screen. A group with every row hidden goes away with them, which is asked of the layout rather than of the widgets: a child of a window that has not been shown yet reads as hidden whether or not anything hid it. The settings reached for rarely sit behind one switch. Nothing is switched off by it -- a held-back row still comes and goes with the type exactly as it did. The section box is deliberately not among them: it is what places a section, and hiding it would leave the side margins looking like the only way to. One bug fell out of it. Changing a section's type rebuilt the entry table and left it empty for the next read to believe, which threw the entries away -- the one thing changing a type is documented never to do. The table's entries are now taken out through the old columns and put back through the new ones. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SctZkVkj2RxyH7oJUvH6wQ --- CMakeLists.txt | 2 + data/locale/en-US.ini | 36 +++ src/model/CreditsModel.cpp | 100 ++++++ src/model/CreditsModel.hpp | 32 ++ src/ui/CollapsibleGroup.cpp | 92 ++++++ src/ui/CollapsibleGroup.hpp | 68 ++++ src/ui/SectionEditor.cpp | 578 ++++++++++++++++++++++++++-------- src/ui/SectionEditor.hpp | 71 +++++ tests/suites/SectionTypes.cpp | 56 ++++ 9 files changed, 902 insertions(+), 133 deletions(-) create mode 100644 src/ui/CollapsibleGroup.cpp create mode 100644 src/ui/CollapsibleGroup.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a56abca..a3ec164 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,8 @@ target_sources( src/plugin-main.cpp src/source/CreditsSource.cpp src/source/CreditsSource.hpp + src/ui/CollapsibleGroup.cpp + src/ui/CollapsibleGroup.hpp src/ui/CsvImportDialog.cpp src/ui/CsvImportDialog.hpp src/ui/DesignerDialog.cpp diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index b40f1ed..d13151a 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -71,6 +71,42 @@ Designer.ImportJson="Import JSON..." Designer.ExportJson="Export JSON..." Designer.SectionType="Section Type" +Designer.TypeSubtitle="With a subtitle under it" +Designer.TypeSubtitle.Tip="Adds a second line under the heading, styled separately. Leave it empty and nothing is drawn." +Designer.TypeLogo="With a logo beside it" +Designer.TypeLogo.Tip="Puts an image next to the words. Which side it sits on, and how close, are below." +Designer.TypeLogoOnly="A logo instead of words" +Designer.TypeLogoOnly.Tip="For a wordmark used as the heading itself, with no text beside it." +Designer.BaseType.text_list="List" +Designer.ListContent="This List Holds" +Designer.ListContent.Text="One line each" +Designer.ListContent.Pairs="A line and a smaller one under it" +Designer.ListContent.Logos="Images" +Designer.ShowAdvanced="Show every setting" +Designer.ShowAdvanced.Tip="Reveals the settings that are rarely needed — where the section sits on the canvas, the fine spacing of a divider, the ordering of a pair. Nothing is switched off while they are hidden." +Designer.Group.Content="What This Section Says" +Designer.Group.TypeSettings="%1 Settings" +Designer.Group.Placement="Where It Sits" +Designer.TypeHelp.title="The largest heading. One line, on its own." +Designer.TypeHelp.title_with_subtitle="A large heading with a second, smaller line under it." +Designer.TypeHelp.title_with_logo="A large heading with an image beside it." +Designer.TypeHelp.title_with_subtitle_logo="A large heading and its subtitle, with an image beside the pair." +Designer.TypeHelp.logo_title="An image used as the heading itself — a wordmark, with no text." +Designer.TypeHelp.header="A smaller heading, for naming the run of credits under it." +Designer.TypeHelp.header_with_subtitle="A heading with a second, smaller line under it." +Designer.TypeHelp.header_with_logo="A heading with an image beside it." +Designer.TypeHelp.header_with_subtitle_logo="A heading and its subtitle, with an image beside the pair." +Designer.TypeHelp.logo_header="An image used as the heading itself, at a heading's size." +Designer.TypeHelp.bridged="Two columns of text joined across the gap — Director . . . . . . Jane Doe." +Designer.TypeHelp.text_list="A run of single lines, one credit each." +Designer.TypeHelp.title_subtitle_list="A run of pairs: a line with a smaller one under it, such as a role over the name that holds it." +Designer.TypeHelp.logo_list="A run of images, one under the next." +Designer.TypeHelp.multi_text_list="A run of single lines, spread over several columns." +Designer.TypeHelp.multi_title_subtitle_list="A run of pairs, spread over several columns." +Designer.TypeHelp.multi_logo_list="A grid of images — a wall of sponsor marks." +Designer.TypeHelp.section_divider="An ornamental rule, composed from its two ends, the rule between them, and whatever sits in the middle." +Designer.TypeHelp.spacer="A measured run of blank space." +Designer.TypeHelp.sticky_block="Holds the sections inside it still on the frame while the rest of the roll scrolls past behind them — a closing card that stays up, and can be the end of the roll." Designer.Label="List Label" Designer.LabelPlaceholder="Optional name shown in the section list" Designer.Visible="Include this section in the roll" diff --git a/src/model/CreditsModel.cpp b/src/model/CreditsModel.cpp index 6cad477..9d8a22d 100644 --- a/src/model/CreditsModel.cpp +++ b/src/model/CreditsModel.cpp @@ -264,6 +264,106 @@ const QVector &allDividerPieceKinds() return kinds; } +SectionTypeSwitches decomposeSectionType(SectionType type) +{ + SectionTypeSwitches switches; + + switch (type) { + case SectionType::Title: + case SectionType::TitleWithSubtitle: + case SectionType::TitleWithLogo: + case SectionType::TitleWithSubtitleAndLogo: + case SectionType::LogoTitle: + switches.base = SectionType::Title; + break; + + case SectionType::Header: + case SectionType::HeaderWithSubtitle: + case SectionType::HeaderWithLogo: + case SectionType::HeaderWithSubtitleAndLogo: + case SectionType::LogoHeader: + switches.base = SectionType::Header; + break; + + case SectionType::TextList: + case SectionType::TitleSubtitleList: + case SectionType::LogoList: + case SectionType::MultiTextList: + case SectionType::MultiTitleSubtitleList: + case SectionType::MultiLogoList: + switches.base = SectionType::TextList; + break; + + default: + /* Every other type is its own base and carries no switches at all. */ + switches.base = type; + return switches; + } + + if (switches.base == SectionType::TextList) { + switch (type) { + case SectionType::TitleSubtitleList: + case SectionType::MultiTitleSubtitleList: + switches.content = SectionListContent::Pairs; + break; + case SectionType::LogoList: + case SectionType::MultiLogoList: + switches.content = SectionListContent::Logos; + break; + default: + switches.content = SectionListContent::Text; + break; + } + + switches.multiColumn = sectionUsesColumns(type); + return switches; + } + + /* + * A heading is either a logo or words, so "logo only" is the absence of text rather than a + * flag of its own -- which is what keeps the two switches from ever both being on. + */ + switches.logoOnly = sectionUsesLogos(type) && !sectionUsesText(type); + switches.logo = sectionUsesLogos(type) && sectionUsesText(type); + switches.subtitle = sectionUsesSubtitles(type); + + return switches; +} + +SectionType composeSectionType(const SectionTypeSwitches &switches) +{ + const bool title = switches.base == SectionType::Title; + + if (title || switches.base == SectionType::Header) { + if (switches.logoOnly) + return title ? SectionType::LogoTitle : SectionType::LogoHeader; + if (switches.subtitle && switches.logo) + return title ? SectionType::TitleWithSubtitleAndLogo + : SectionType::HeaderWithSubtitleAndLogo; + if (switches.subtitle) + return title ? SectionType::TitleWithSubtitle : SectionType::HeaderWithSubtitle; + if (switches.logo) + return title ? SectionType::TitleWithLogo : SectionType::HeaderWithLogo; + + return title ? SectionType::Title : SectionType::Header; + } + + if (switches.base == SectionType::TextList) { + switch (switches.content) { + case SectionListContent::Pairs: + return switches.multiColumn ? SectionType::MultiTitleSubtitleList + : SectionType::TitleSubtitleList; + case SectionListContent::Logos: + return switches.multiColumn ? SectionType::MultiLogoList : SectionType::LogoList; + case SectionListContent::Text: + default: + return switches.multiColumn ? SectionType::MultiTextList : SectionType::TextList; + } + } + + return switches.base; +} + const char *stickyAnchorId(StickyAnchor anchor) { switch (anchor) { diff --git a/src/model/CreditsModel.hpp b/src/model/CreditsModel.hpp index 4ece98a..cb54b3e 100644 --- a/src/model/CreditsModel.hpp +++ b/src/model/CreditsModel.hpp @@ -99,6 +99,38 @@ bool sectionUsesSubtitles(SectionType type); */ bool sectionUsesSecondaryText(SectionType type); +/* What a list holds, which with a column count is the whole of what tells the six list types apart. */ +enum class SectionListContent { Text, Pairs, Logos }; + +/* + * A section type taken apart into the handful of plain answers it stands for. + * + * The nineteen shapes of heading and list are not nineteen different things: they are two + * headings, a list and a bridged row, crossed with whether a subtitle is stacked under it, whether + * a logo sits beside it, what a list holds, and how many columns it runs over. A picker that + * offers all of them at once asks one hard question where this asks three easy ones. + * + * It lives here rather than in the designer because it is a property of the type table -- the + * *editor's* decision is which base types to offer -- and because a mapping that has to be exactly + * reversible is worth a test that can be written without a window on screen. + */ +struct SectionTypeSwitches { + SectionType base = SectionType::Title; + /* Headings only. */ + bool subtitle = false; + bool logo = false; + bool logoOnly = false; + /* Lists only. */ + SectionListContent content = SectionListContent::Text; + bool multiColumn = false; +}; + +/* The base type and the switches a type stands for. */ +SectionTypeSwitches decomposeSectionType(SectionType type); + +/* And back again: `composeSectionType(decomposeSectionType(t)) == t` for every type. */ +SectionType composeSectionType(const SectionTypeSwitches &switches); + /* * Which part of a sticky block is pinned, and to what. * diff --git a/src/ui/CollapsibleGroup.cpp b/src/ui/CollapsibleGroup.cpp new file mode 100644 index 0000000..9fc4edc --- /dev/null +++ b/src/ui/CollapsibleGroup.cpp @@ -0,0 +1,92 @@ +/* +Closing Time +Copyright (C) 2026 Voidscape Development + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include "ui/CollapsibleGroup.hpp" + +#include +#include +#include + +namespace closingtime { + +CollapsibleGroup::CollapsibleGroup(const QString &title, QWidget *parent) : QWidget(parent), titleText(title) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(2); + + header = new QToolButton(this); + header->setCheckable(true); + header->setChecked(true); + header->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + header->setArrowType(Qt::DownArrow); + /* + * Flat and full width, so the row reads as a heading over the settings under it rather than + * as a button among them -- which is what it is: the fold is a convenience, the name is the + * point. + */ + header->setAutoRaise(true); + header->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + layout->addWidget(header); + + body = new QWidget(this); + auto *bodyLayout = new QVBoxLayout(body); + bodyLayout->setContentsMargins(12, 0, 0, 6); + layout->addWidget(body); + + connect(header, &QToolButton::toggled, this, [this](bool expanded) { + body->setVisible(expanded); + refreshHeader(); + }); + + refreshHeader(); +} + +void CollapsibleGroup::addWidget(QWidget *widget) +{ + body->layout()->addWidget(widget); +} + +void CollapsibleGroup::addLayout(QLayout *layout) +{ + static_cast(body->layout())->addLayout(layout); +} + +void CollapsibleGroup::setTitle(const QString &title) +{ + titleText = title; + refreshHeader(); +} + +bool CollapsibleGroup::isExpanded() const +{ + return header->isChecked(); +} + +void CollapsibleGroup::setExpanded(bool expanded) +{ + header->setChecked(expanded); +} + +void CollapsibleGroup::refreshHeader() +{ + header->setText(titleText); + header->setArrowType(header->isChecked() ? Qt::DownArrow : Qt::RightArrow); +} + +} // namespace closingtime diff --git a/src/ui/CollapsibleGroup.hpp b/src/ui/CollapsibleGroup.hpp new file mode 100644 index 0000000..d48e2b5 --- /dev/null +++ b/src/ui/CollapsibleGroup.hpp @@ -0,0 +1,68 @@ +/* +Closing Time +Copyright (C) 2026 Voidscape Development + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#pragma once + +#include + +class QLayout; +class QToolButton; +class QVBoxLayout; + +namespace closingtime { + +/* + * A named run of settings that folds away to its own title. + * + * The section editor is one long column of controls, and which of them apply changes with the type + * being edited -- so the pane is either a wall of rows or a list of them with no shape to it. A + * group gives each run a name and a place, and folding one away is how somebody working on the + * words gets the geometry out of the way without losing where it was. + * + * A QGroupBox with `setCheckable` was the obvious thing and is the wrong thing: its checkbox reads + * as *switching the group off*, which is exactly what the checkable groups already in this editor + * mean (a secondary style either applies or it does not). A disclosure triangle says "there is more + * here" and nothing else, which is the whole of what this does. + * + * The fold state is per group and per window; nothing about it is written to the document. + */ +class CollapsibleGroup : public QWidget { + Q_OBJECT + +public: + explicit CollapsibleGroup(const QString &title, QWidget *parent = nullptr); + + /* Where a caller puts the group's own widgets. */ + QWidget *content() const { return body; } + void addWidget(QWidget *widget); + void addLayout(QLayout *layout); + + void setTitle(const QString &title); + + bool isExpanded() const; + void setExpanded(bool expanded); + +private: + void refreshHeader(); + + QToolButton *header = nullptr; + QWidget *body = nullptr; + QString titleText; +}; + +} // namespace closingtime diff --git a/src/ui/SectionEditor.cpp b/src/ui/SectionEditor.cpp index 0af7525..00f6cdb 100644 --- a/src/ui/SectionEditor.cpp +++ b/src/ui/SectionEditor.cpp @@ -43,6 +43,7 @@ with this program. If not, see #include #include +#include #include #include "render/AnimatedLogo.hpp" @@ -201,6 +202,53 @@ enum PieceColumn { constexpr int kPieceKindColumnWidth = 110; constexpr int kPieceSizeColumnWidth = 70; +/* + * The types the picker offers, from which the rest are composed by the switches beside it. + * + * Deliberately a list here rather than a flag on the type table: which types a *picker* offers is + * a decision about this editor, where taking a type apart into switches is a property of the type + * table itself -- see decomposeSectionType. Everything the twenty types can express is still + * reachable, so nothing has been taken away by asking three easy questions instead of one hard one. + */ +const QVector &baseSectionTypes() +{ + static const QVector types = { + SectionType::Title, SectionType::Header, SectionType::TextList, SectionType::Bridged, + SectionType::Spacer, SectionType::SectionDivider, SectionType::StickyBlock, + }; + return types; +} + +/* + * What the picker calls a base type. + * + * Ordinarily the type's own name, but a base stands for every type composed from it, and one of + * them is named after only the first: "Text List" is a poor name for the entry that also produces + * a list of logos. A locale string per base overrides it where that matters. + */ +QString baseTypeLabel(SectionType type) +{ + const QString key = QStringLiteral("Designer.BaseType.") + QString::fromLatin1(sectionTypeId(type)); + const QString text = QString::fromUtf8(obs_module_text(key.toUtf8().constData())); + + return text == key ? QString::fromUtf8(sectionTypeName(type)) : text; +} + +/* True when the base type is one of the two headings, which are the ones the switches apply to. */ +bool isHeadingBase(SectionType base) +{ + return base == SectionType::Title || base == SectionType::Header; +} + +/* The one-line description of a type, or nothing when the locale carries none. */ +QString sectionTypeHelp(SectionType type) +{ + const QString key = QStringLiteral("Designer.TypeHelp.") + QString::fromLatin1(sectionTypeId(type)); + const QString text = QString::fromUtf8(obs_module_text(key.toUtf8().constData())); + + return text == key ? QString() : text; +} + void addAlignmentOptions(QComboBox *box) { box->addItem(moduleText("Designer.Align.Left"), static_cast(HAlign::Left)); @@ -623,25 +671,72 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) emitChanged(); }; + /* + * Seven base types and a few switches rather than twenty entries in one list. The twenty are + * still what the document holds -- see composedType -- but "a title, with a subtitle, with a + * logo" is three plain answers where picking "Title w/ Subtitle & Logo" out of a list of ten + * headings is one hard question. + */ typeBox = new QComboBox(this); - for (SectionType type : allSectionTypes()) - typeBox->addItem(QString::fromUtf8(sectionTypeName(type)), static_cast(type)); - form->addRow(moduleText("Designer.SectionType"), typeBox); + for (SectionType type : baseSectionTypes()) + typeBox->addItem(baseTypeLabel(type), static_cast(type)); + addRow(moduleText("Designer.SectionType"), typeBox); + + typeHelp = new QLabel(this); + typeHelp->setWordWrap(true); + typeHelp->setEnabled(false); + addRow(QString(), typeHelp); + + typeSubtitle = new QCheckBox(moduleText("Designer.TypeSubtitle"), this); + typeSubtitle->setToolTip(moduleText("Designer.TypeSubtitle.Tip")); + addRow(QString(), typeSubtitle); + + typeLogo = new QCheckBox(moduleText("Designer.TypeLogo"), this); + typeLogo->setToolTip(moduleText("Designer.TypeLogo.Tip")); + addRow(QString(), typeLogo); + + typeLogoOnly = new QCheckBox(moduleText("Designer.TypeLogoOnly"), this); + typeLogoOnly->setToolTip(moduleText("Designer.TypeLogoOnly.Tip")); + addRow(QString(), typeLogoOnly); + + typeListContent = new QComboBox(this); + typeListContent->addItem(moduleText("Designer.ListContent.Text"), + static_cast(SectionListContent::Text)); + typeListContent->addItem(moduleText("Designer.ListContent.Pairs"), + static_cast(SectionListContent::Pairs)); + typeListContent->addItem(moduleText("Designer.ListContent.Logos"), + static_cast(SectionListContent::Logos)); + addRow(moduleText("Designer.ListContent"), typeListContent); labelEdit = new QLineEdit(this); labelEdit->setPlaceholderText(moduleText("Designer.LabelPlaceholder")); - form->addRow(moduleText("Designer.Label"), labelEdit); + addRow(moduleText("Designer.Label"), labelEdit); visibleBox = new QCheckBox(moduleText("Designer.Visible"), this); - form->addRow(QString(), visibleBox); + addRow(QString(), visibleBox); + + showAdvanced = new QCheckBox(moduleText("Designer.ShowAdvanced"), this); + showAdvanced->setToolTip(moduleText("Designer.ShowAdvanced.Tip")); + addRow(QString(), showAdvanced); + + /* + * From here the rows go into named groups that fold away. The order is the order the work is + * done in: what the section says, then how this kind of section is put together, then where + * it sits on the canvas -- with the styles and the tables under them in groups of their own. + */ + contentGroup = new CollapsibleGroup(moduleText("Designer.Group.Content"), this); + form = new QFormLayout(); + contentForm = form; + contentGroup->addLayout(form); + outer->addWidget(contentGroup); textEdit = new QPlainTextEdit(this); textEdit->setMaximumHeight(80); - form->addRow(moduleText("Designer.Text"), textEdit); + addRow(moduleText("Designer.Text"), textEdit); subtitleEdit = new QPlainTextEdit(this); subtitleEdit->setMaximumHeight(80); - form->addRow(moduleText("Designer.Subtitle"), subtitleEdit); + addRow(moduleText("Designer.Subtitle"), subtitleEdit); auto *logoRow = new QWidget(this); auto *logoLayout = new QHBoxLayout(logoRow); @@ -651,12 +746,12 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) logoBrowse->setText(QStringLiteral("...")); logoLayout->addWidget(logoPath); logoLayout->addWidget(logoBrowse); - form->addRow(moduleText("Designer.Logo"), logoRow); + addRow(moduleText("Designer.Logo"), logoRow); logoHeight = new QSpinBox(this); logoHeight->setRange(1, 4096); logoHeight->setSuffix(QStringLiteral(" px")); - form->addRow(moduleText("Designer.LogoHeight"), logoHeight); + addRow(moduleText("Designer.LogoHeight"), logoHeight); /* * Playback, on one row: three settings that are only ever read together, and that appear at @@ -680,35 +775,47 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) playbackLayout->addWidget(logoStartOnEnter); playbackLayout->addWidget(logoSpeed); playbackLayout->addStretch(); - form->addRow(moduleText("Designer.LogoPlayback"), playbackRow); + addRow(moduleText("Designer.LogoPlayback"), playbackRow); logoAnimatedShadow = new QCheckBox(moduleText("Designer.LogoAnimatedShadow"), this); logoAnimatedShadow->setToolTip(moduleText("Designer.LogoAnimatedShadow.Tip")); - form->addRow(QString(), logoAnimatedShadow); + addRow(QString(), logoAnimatedShadow); + + /* + * From here on the rows describe how this kind of section is put together rather than what + * it says: where the logo sits against the words, what the bridge is made of, what a divider + * is composed from, how a block pins. The group is retitled after the selected type, since + * "Bridge settings" and "Divider settings" are never on screen at the same time. + */ + typeSettingsGroup = new CollapsibleGroup(moduleText("Designer.Group.TypeSettings"), this); + form = new QFormLayout(); + typeSettingsForm = form; + typeSettingsGroup->addLayout(form); + outer->addWidget(typeSettingsGroup); logoPlacement = new QComboBox(this); logoPlacement->addItem(moduleText("Designer.LogoPlacement.Hug"), static_cast(LogoPlacement::Hug)); logoPlacement->addItem(moduleText("Designer.LogoPlacement.Edge"), static_cast(LogoPlacement::Edge)); logoPlacement->addItem(moduleText("Designer.LogoPlacement.Bridged"), static_cast(LogoPlacement::Bridged)); - form->addRow(moduleText("Designer.LogoPlacement"), logoPlacement); + addRow(moduleText("Designer.LogoPlacement"), logoPlacement); logoSide = new QComboBox(this); logoSide->addItem(moduleText("Designer.LogoSide.Left"), static_cast(LogoSide::Left)); logoSide->addItem(moduleText("Designer.LogoSide.Right"), static_cast(LogoSide::Right)); - form->addRow(moduleText("Designer.LogoSide"), logoSide); + addRow(moduleText("Designer.LogoSide"), logoSide); logoGap = new QSpinBox(this); logoGap->setRange(0, 2048); logoGap->setSuffix(QStringLiteral(" px")); - form->addRow(moduleText("Designer.LogoGap"), logoGap); + addRow(moduleText("Designer.LogoGap"), logoGap); bridgeType = new QComboBox(this); for (BridgeType type : allBridgeTypes()) bridgeType->addItem(bridgeTypeText(type), static_cast(type)); - form->addRow(moduleText("Designer.BridgeType"), bridgeType); + addRow(moduleText("Designer.BridgeType"), bridgeType); bridgeEdit = new QLineEdit(this); - form->addRow(moduleText("Designer.Bridge"), bridgeEdit); + addRow(moduleText("Designer.Bridge"), bridgeEdit); auto *bridgeSvgRow = new QWidget(this); auto *bridgeSvgLayout = new QHBoxLayout(bridgeSvgRow); @@ -718,62 +825,62 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) bridgeSvgBrowse->setText(QStringLiteral("...")); bridgeSvgLayout->addWidget(bridgeSvgPath); bridgeSvgLayout->addWidget(bridgeSvgBrowse); - form->addRow(moduleText("Designer.BridgeSvg"), bridgeSvgRow); + addRow(moduleText("Designer.BridgeSvg"), bridgeSvgRow); bridgeThickness = new QSpinBox(this); bridgeThickness->setRange(1, 1024); bridgeThickness->setSuffix(QStringLiteral(" px")); bridgeThickness->setToolTip(moduleText("Designer.BridgeThickness.Tip")); - form->addRow(moduleText("Designer.BridgeThickness"), bridgeThickness); + addRow(moduleText("Designer.BridgeThickness"), bridgeThickness); bridgeOffset = new QSpinBox(this); bridgeOffset->setRange(-1024, 1024); bridgeOffset->setSuffix(QStringLiteral(" px")); bridgeOffset->setToolTip(moduleText("Designer.BridgeOffset.Tip")); - form->addRow(moduleText("Designer.BridgeOffset"), bridgeOffset); + addRow(moduleText("Designer.BridgeOffset"), bridgeOffset); bridgeGap = new QSpinBox(this); bridgeGap->setRange(0, 2048); bridgeGap->setSuffix(QStringLiteral(" px")); bridgeGap->setToolTip(moduleText("Designer.BridgeGap.Tip")); - form->addRow(moduleText("Designer.BridgeGap"), bridgeGap); + addRow(moduleText("Designer.BridgeGap"), bridgeGap); bridgeMinGap = new QSpinBox(this); bridgeMinGap->setRange(0, 2048); bridgeMinGap->setSuffix(QStringLiteral(" px")); bridgeMinGap->setToolTip(moduleText("Designer.BridgeMinGap.Tip")); - form->addRow(moduleText("Designer.BridgeMinGap"), bridgeMinGap); + addRow(moduleText("Designer.BridgeMinGap"), bridgeMinGap); bridgeTint = new QCheckBox(moduleText("Designer.BridgeTint"), this); - form->addRow(QString(), bridgeTint); + addRow(QString(), bridgeTint); bridgeFill = new QComboBox(this); bridgeFill->addItem(moduleText("Designer.BridgeFill.Fixed"), static_cast(BridgeFill::Fixed)); bridgeFill->addItem(moduleText("Designer.BridgeFill.Repeat"), static_cast(BridgeFill::Repeat)); bridgeFill->addItem(moduleText("Designer.BridgeFill.Stretch"), static_cast(BridgeFill::Stretch)); - form->addRow(moduleText("Designer.BridgeFill"), bridgeFill); + addRow(moduleText("Designer.BridgeFill"), bridgeFill); bridgeSizing = new QComboBox(this); bridgeSizing->addItem(moduleText("Designer.BridgeSizing.Split"), static_cast(BridgeSizing::Split)); bridgeSizing->addItem(moduleText("Designer.BridgeSizing.Natural"), static_cast(BridgeSizing::Natural)); - form->addRow(moduleText("Designer.BridgeSizing"), bridgeSizing); + addRow(moduleText("Designer.BridgeSizing"), bridgeSizing); bridgeSplit = new QSpinBox(this); bridgeSplit->setRange(0, 100); bridgeSplit->setSuffix(QStringLiteral(" %")); bridgeSplit->setToolTip(moduleText("Designer.BridgeSplit.Tip")); - form->addRow(moduleText("Designer.BridgeSplit"), bridgeSplit); + addRow(moduleText("Designer.BridgeSplit"), bridgeSplit); bridgeRowAlign = new QComboBox(this); addAlignmentOptions(bridgeRowAlign); - form->addRow(moduleText("Designer.BridgeRowAlign"), bridgeRowAlign); + addRow(moduleText("Designer.BridgeRowAlign"), bridgeRowAlign); bridgeSpanEmpty = new QCheckBox(moduleText("Designer.BridgeSpanEmpty"), this); - form->addRow(QString(), bridgeSpanEmpty); + addRow(QString(), bridgeSpanEmpty); rowSubtitles = new QCheckBox(moduleText("Designer.RowSubtitles"), this); rowSubtitles->setToolTip(moduleText("Designer.RowSubtitles.Tip")); - form->addRow(QString(), rowSubtitles); + addRow(QString(), rowSubtitles); /* * The divider's three artwork slots. Each picker is filled from the shape library filtered @@ -785,7 +892,7 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) *box = new QComboBox(this); for (DividerShape shape : dividerShapesForRole(role)) (*box)->addItem(dividerShapeText(shape), static_cast(shape)); - form->addRow(moduleText(boxKey), *box); + addRow(moduleText(boxKey), *box); auto *row = new QWidget(this); auto *layout = new QHBoxLayout(row); @@ -795,7 +902,7 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) browse->setText(QStringLiteral("...")); layout->addWidget(*path); layout->addWidget(browse); - form->addRow(moduleText(pathKey), row); + addRow(moduleText(pathKey), row); QLineEdit *target = *path; connect(browse, &QToolButton::clicked, this, [this, target] { browseForDividerSvg(target); }); @@ -803,7 +910,7 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) dividerMirrorEnds = new QCheckBox(moduleText("Designer.DividerMirrorEnds"), this); dividerMirrorEnds->setToolTip(moduleText("Designer.DividerMirrorEnds.Tip")); - form->addRow(QString(), dividerMirrorEnds); + addRow(QString(), dividerMirrorEnds); addShapeRow(÷rArm, ÷rArmSvgPath, DividerRoleArm, "Designer.DividerArm", "Designer.DividerArmSvg"); @@ -811,68 +918,68 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) dividerThickness->setRange(1, 1024); dividerThickness->setSuffix(QStringLiteral(" px")); dividerThickness->setToolTip(moduleText("Designer.DividerThickness.Tip")); - form->addRow(moduleText("Designer.DividerThickness"), dividerThickness); + addRow(moduleText("Designer.DividerThickness"), dividerThickness); dividerGap = new QSpinBox(this); dividerGap->setRange(0, 2048); dividerGap->setSuffix(QStringLiteral(" px")); dividerGap->setToolTip(moduleText("Designer.DividerGap.Tip")); - form->addRow(moduleText("Designer.DividerGap"), dividerGap); + addRow(moduleText("Designer.DividerGap"), dividerGap); dividerPieceGap = new QSpinBox(this); dividerPieceGap->setRange(0, 2048); dividerPieceGap->setSuffix(QStringLiteral(" px")); - form->addRow(moduleText("Designer.DividerPieceGap"), dividerPieceGap); + addRow(moduleText("Designer.DividerPieceGap"), dividerPieceGap); dividerRules = new QSpinBox(this); dividerRules->setRange(1, 16); dividerRules->setToolTip(moduleText("Designer.DividerRules.Tip")); - form->addRow(moduleText("Designer.DividerRules"), dividerRules); + addRow(moduleText("Designer.DividerRules"), dividerRules); dividerRuleGap = new QSpinBox(this); dividerRuleGap->setRange(0, 2048); dividerRuleGap->setSuffix(QStringLiteral(" px")); - form->addRow(moduleText("Designer.DividerRuleGap"), dividerRuleGap); + addRow(moduleText("Designer.DividerRuleGap"), dividerRuleGap); dividerRuleInset = new QSpinBox(this); dividerRuleInset->setRange(0, 4096); dividerRuleInset->setSuffix(QStringLiteral(" px")); dividerRuleInset->setToolTip(moduleText("Designer.DividerRuleInset.Tip")); - form->addRow(moduleText("Designer.DividerRuleInset"), dividerRuleInset); + addRow(moduleText("Designer.DividerRuleInset"), dividerRuleInset); dividerTint = new QCheckBox(moduleText("Designer.DividerTint"), this); - form->addRow(QString(), dividerTint); + addRow(QString(), dividerTint); columns = new QSpinBox(this); columns->setRange(1, 12); - form->addRow(moduleText("Designer.Columns"), columns); + addRow(moduleText("Designer.Columns"), columns); columnGap = new QSpinBox(this); columnGap->setRange(0, 2048); columnGap->setSuffix(QStringLiteral(" px")); - form->addRow(moduleText("Designer.ColumnGap"), columnGap); + addRow(moduleText("Designer.ColumnGap"), columnGap); fillOrder = new QComboBox(this); fillOrder->addItem(moduleText("Designer.FillDown"), 0); fillOrder->addItem(moduleText("Designer.FillAcross"), 1); - form->addRow(moduleText("Designer.FillOrder"), fillOrder); + addRow(moduleText("Designer.FillOrder"), fillOrder); entryGap = new QSpinBox(this); entryGap->setRange(0, 2048); entryGap->setSuffix(QStringLiteral(" px")); - form->addRow(moduleText("Designer.EntryGap"), entryGap); + addRow(moduleText("Designer.EntryGap"), entryGap); subtitleGap = new QSpinBox(this); subtitleGap->setRange(0, 2048); subtitleGap->setSuffix(QStringLiteral(" px")); subtitleGap->setToolTip(moduleText("Designer.SubtitleGap.Tip")); - form->addRow(moduleText("Designer.SubtitleGap"), subtitleGap); + addRow(moduleText("Designer.SubtitleGap"), subtitleGap); subtitleOrder = new QComboBox(this); subtitleOrder->addItem(moduleText("Designer.SubtitleOrder.TitleFirst"), 0); subtitleOrder->addItem(moduleText("Designer.SubtitleOrder.SubtitleFirst"), 1); subtitleOrder->setToolTip(moduleText("Designer.SubtitleOrder.Tip")); - form->addRow(moduleText("Designer.SubtitleOrder"), subtitleOrder); + addRow(moduleText("Designer.SubtitleOrder"), subtitleOrder); /* * The sticky block's own rows. The pin is a pair of points -- one on the block, one down the @@ -884,19 +991,19 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) stickyAnchor->addItem(moduleText("Designer.StickyAnchor.Center"), static_cast(StickyAnchor::Center)); stickyAnchor->addItem(moduleText("Designer.StickyAnchor.Bottom"), static_cast(StickyAnchor::Bottom)); stickyAnchor->setToolTip(moduleText("Designer.StickyAnchor.Tip")); - form->addRow(moduleText("Designer.StickyAnchor"), stickyAnchor); + addRow(moduleText("Designer.StickyAnchor"), stickyAnchor); stickyCanvasPosition = new QSpinBox(this); stickyCanvasPosition->setRange(0, 100); stickyCanvasPosition->setSuffix(QStringLiteral(" %")); stickyCanvasPosition->setToolTip(moduleText("Designer.StickyCanvasPosition.Tip")); - form->addRow(moduleText("Designer.StickyCanvasPosition"), stickyCanvasPosition); + addRow(moduleText("Designer.StickyCanvasPosition"), stickyCanvasPosition); stickyOffset = new QSpinBox(this); stickyOffset->setRange(-4096, 4096); stickyOffset->setSuffix(QStringLiteral(" px")); stickyOffset->setToolTip(moduleText("Designer.StickyOffset.Tip")); - form->addRow(moduleText("Designer.StickyOffset"), stickyOffset); + addRow(moduleText("Designer.StickyOffset"), stickyOffset); stickyHold = new QDoubleSpinBox(this); stickyHold->setRange(0.0, 3600.0); @@ -904,11 +1011,11 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) stickyHold->setSingleStep(0.5); stickyHold->setSuffix(moduleText("Designer.Seconds")); stickyHold->setToolTip(moduleText("Designer.StickyHold.Tip")); - form->addRow(moduleText("Designer.StickyHold"), stickyHold); + addRow(moduleText("Designer.StickyHold"), stickyHold); stickyHoldForever = new QCheckBox(moduleText("Designer.StickyHoldForever"), this); stickyHoldForever->setToolTip(moduleText("Designer.StickyHoldForever.Tip")); - form->addRow(QString(), stickyHoldForever); + addRow(QString(), stickyHoldForever); stickyRelease = new QComboBox(this); stickyRelease->addItem(moduleText("Designer.StickyRelease.EndAtHold"), @@ -918,7 +1025,7 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) stickyRelease->addItem(moduleText("Designer.StickyRelease.ResumeEndAtHold"), static_cast(StickyRelease::ResumeEndAtHold)); stickyRelease->setToolTip(moduleText("Designer.StickyRelease.Tip")); - form->addRow(moduleText("Designer.StickyRelease"), stickyRelease); + addRow(moduleText("Designer.StickyRelease"), stickyRelease); /* * Said on the form rather than in a tooltip, because a block that holds for ever and is set @@ -927,52 +1034,59 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) */ stickyForeverWarning = new QLabel(moduleText("Designer.StickyHoldForever.Warning"), this); stickyForeverWarning->setWordWrap(true); - form->addRow(QString(), stickyForeverWarning); + addRow(QString(), stickyForeverWarning); stickyBackdrop = new QCheckBox(moduleText("Designer.StickyBackdrop"), this); stickyBackdrop->setToolTip(moduleText("Designer.StickyBackdrop.Tip")); - form->addRow(QString(), stickyBackdrop); + addRow(QString(), stickyBackdrop); stickyBackdropColour = new ColourButton(this); stickyBackdropColour->setDialogTitle(moduleText("Designer.StickyBackdropColor")); - form->addRow(moduleText("Designer.StickyBackdropColor"), stickyBackdropColour); + addRow(moduleText("Designer.StickyBackdropColor"), stickyBackdropColour); stickyBackdropPadding = new QSpinBox(this); stickyBackdropPadding->setRange(0, 2048); stickyBackdropPadding->setSuffix(QStringLiteral(" px")); stickyBackdropPadding->setToolTip(moduleText("Designer.StickyBackdropPadding.Tip")); - form->addRow(moduleText("Designer.StickyBackdropPadding"), stickyBackdropPadding); + addRow(moduleText("Designer.StickyBackdropPadding"), stickyBackdropPadding); spacerHeight = new QSpinBox(this); spacerHeight->setRange(0, 20000); spacerHeight->setSuffix(QStringLiteral(" px")); - form->addRow(moduleText("Designer.SpacerHeight"), spacerHeight); + addRow(moduleText("Designer.SpacerHeight"), spacerHeight); + + /* Where the section sits on the canvas, which is the same set of questions for every type. */ + placementGroup = new CollapsibleGroup(moduleText("Designer.Group.Placement"), this); + form = new QFormLayout(); + placementForm = form; + placementGroup->addLayout(form); + outer->addWidget(placementGroup); paddingTop = new QSpinBox(this); paddingTop->setRange(0, 20000); paddingTop->setSuffix(QStringLiteral(" px")); - form->addRow(moduleText("Designer.PaddingTop"), paddingTop); + addRow(moduleText("Designer.PaddingTop"), paddingTop); paddingBottom = new QSpinBox(this); paddingBottom->setRange(0, 20000); paddingBottom->setSuffix(QStringLiteral(" px")); - form->addRow(moduleText("Designer.PaddingBottom"), paddingBottom); + addRow(moduleText("Designer.PaddingBottom"), paddingBottom); marginX = new QSpinBox(this); marginX->setRange(0, 4096); marginX->setSuffix(QStringLiteral(" px")); - form->addRow(moduleText("Designer.MarginX"), marginX); + addRow(moduleText("Designer.MarginX"), marginX); sectionWidth = new QSpinBox(this); sectionWidth->setRange(1, 100); sectionWidth->setSuffix(QStringLiteral(" %")); sectionWidth->setToolTip(moduleText("Designer.SectionWidth.Tip")); - form->addRow(moduleText("Designer.SectionWidth"), sectionWidth); + addRow(moduleText("Designer.SectionWidth"), sectionWidth); sectionAlign = new QComboBox(this); addAlignmentOptions(sectionAlign); sectionAlign->setToolTip(moduleText("Designer.SectionAlign.Tip")); - form->addRow(moduleText("Designer.SectionAlign"), sectionAlign); + addRow(moduleText("Designer.SectionAlign"), sectionAlign); auto *styleGroup = new QGroupBox(moduleText("Designer.TextStyle"), this); auto *styleLayout = new QVBoxLayout(styleGroup); @@ -1155,14 +1269,38 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) outer->addStretch(); trailingStretchIndex = outer->count() - 1; - connect(typeBox, &QComboBox::currentIndexChanged, this, [this] { - if (loading) - return; + /* + * The settings held back until the reader asks for everything. + * + * Chosen by how often a roll needs them rather than by how hard they are: the side margins + * that a section box says better, the fine spacing of a divider's stack, the ordering of a + * pair. The section box itself stays out in the open -- it is the setting that places a + * section, and hiding it would leave the margin looking like the only way to. + * Every one of them is a thing somebody eventually wants -- which is why the switch shows + * them rather than the editor deciding they do not exist. + */ + for (QWidget *field : std::initializer_list{marginX, logoAnimatedShadow, bridgeOffset, + bridgeGap, bridgeRowAlign, bridgeSpanEmpty, + bridgeSizing, dividerPieceGap, dividerRuleGap, + dividerRuleInset, dividerTint, bridgeTint, + subtitleOrder, fillOrder, stickyOffset, + stickyBackdropPadding}) + markAdvanced(field); + + /* Every one of these changes which type the picker adds up to, so all of them go one way. */ + connect(typeBox, &QComboBox::currentIndexChanged, this, &SectionEditor::onTypeSwitchChanged); + connect(typeListContent, &QComboBox::currentIndexChanged, this, &SectionEditor::onTypeSwitchChanged); + for (QCheckBox *box : {typeSubtitle, typeLogo, typeLogoOnly}) + connect(box, &QCheckBox::toggled, this, &SectionEditor::onTypeSwitchChanged); - const auto type = static_cast(typeBox->currentData().toInt()); - applyTypeVisibility(type); - rebuildEntryTable(type, rowSubtitles->isChecked()); - emitChanged(); + /* + * The column count is part of what a list *is* -- one column or several is the difference + * between two of the document's types -- so it runs the same path the switches do. + */ + connect(columns, &QSpinBox::valueChanged, this, &SectionEditor::onTypeSwitchChanged); + + connect(showAdvanced, &QCheckBox::toggled, this, [this] { + applyTypeVisibility(composedType()); }); connect(labelEdit, &QLineEdit::textChanged, this, notify); @@ -1197,7 +1335,7 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) connect(box, &QCheckBox::toggled, this, [this] { if (loading) return; - applyTypeVisibility(static_cast(typeBox->currentData().toInt())); + applyTypeVisibility(composedType()); emitChanged(); }); } @@ -1210,7 +1348,7 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) if (loading) return; - applyTypeVisibility(static_cast(typeBox->currentData().toInt())); + applyTypeVisibility(composedType()); emitChanged(); }; @@ -1238,7 +1376,6 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) connect(dividerMirrorEnds, &QCheckBox::toggled, this, revisitVisibility); connect(dividerTint, &QCheckBox::toggled, this, revisitVisibility); - connect(columns, &QSpinBox::valueChanged, this, notify); connect(columnGap, &QSpinBox::valueChanged, this, notify); connect(fillOrder, &QComboBox::currentIndexChanged, this, notify); connect(entryGap, &QSpinBox::valueChanged, this, notify); @@ -1285,11 +1422,9 @@ SectionEditor::SectionEditor(QWidget *parent) : QWidget(parent) if (loading) return; - const Section held = section(); - const QSignalBlocker blocker(entryTable); - rebuildEntryTable(held.type, held.rowSubtitles); - writeEntriesToTable(held); - applyTypeVisibility(held.type); + const SectionType type = composedType(); + applyTypeVisibility(type); + relayoutEntryTable(type); emitChanged(); }); connect(bridgeStyleGroup, &QGroupBox::toggled, this, notify); @@ -1303,7 +1438,7 @@ void SectionEditor::setSection(const Section &source) loading = true; current = source; - selectByData(typeBox, static_cast(source.type)); + showTypeAsSwitches(source.type); labelEdit->setText(source.label); visibleBox->setChecked(source.visible); textEdit->setPlainText(source.text); @@ -1353,7 +1488,12 @@ void SectionEditor::setSection(const Section &source) dividerRuleGap->setValue(qRound(source.dividerRuleGap)); dividerRuleInset->setValue(qRound(source.dividerRuleInset)); dividerTint->setChecked(source.dividerTint); - columns->setValue(source.columns); + /* + * One column or several is the difference between two of the document's types, so the spin + * box shows what the *type* says rather than what the field happens to carry: a list that was + * once three columns wide and is now a plain one reads as one column, not as three. + */ + columns->setValue(sectionUsesColumns(source.type) ? source.columns : 1); columnGap->setValue(source.columnGap); selectByData(fillOrder, source.fillAcross ? 1 : 0); entryGap->setValue(source.entryGap); @@ -1409,7 +1549,7 @@ Section SectionEditor::section() const { Section result = current; - result.type = static_cast(typeBox->currentData().toInt()); + result.type = composedType(); result.label = labelEdit->text(); result.visible = visibleBox->isChecked(); result.text = textEdit->toPlainText(); @@ -1509,8 +1649,153 @@ void SectionEditor::setPresets(const QVector &newPresets) editor->setPresets(presets, editor->presetName(), editor != presetOrigin); } +namespace { + +/* + * True when any row of a form is still showing. + * + * Asked of the layout rather than of the widgets in it, because a widget's own `isHidden` answers a + * different question during construction -- a child of a window that has not been shown yet reads + * as hidden whether or not anything hid it -- and a row nothing has ever set is visible, which is + * exactly what the layout says and what a scan of widgets would get wrong. + */ +bool formHasVisibleRow(const QFormLayout *form) +{ + for (int row = 0; row < form->rowCount(); ++row) { + if (form->isRowVisible(row)) + return true; + } + return false; +} + +} // namespace + +void SectionEditor::addRow(const QString &label, QWidget *field) +{ + form->addRow(label, field); + rowOwner.insert(field, form); +} + +void SectionEditor::setRowVisible(QWidget *field, bool visible) +{ + QFormLayout *owner = rowOwner.value(field); + if (!owner) + return; + + /* + * A held-back row is hidden on top of whatever the type says, never instead of it: the + * switch decides whether a row *may* show, and the type still decides whether it applies. + */ + owner->setRowVisible(field, visible && (showAdvanced->isChecked() || !advancedRows.contains(field))); +} + +void SectionEditor::markAdvanced(QWidget *field) +{ + advancedRows.insert(field); +} + +SectionType SectionEditor::composedType() const +{ + SectionTypeSwitches switches; + switches.base = static_cast(typeBox->currentData().toInt()); + switches.subtitle = typeSubtitle->isChecked(); + switches.logo = typeLogo->isChecked(); + switches.logoOnly = typeLogoOnly->isChecked(); + switches.content = static_cast(typeListContent->currentData().toInt()); + switches.multiColumn = columns->value() > 1; + + return composeSectionType(switches); +} + +void SectionEditor::showTypeAsSwitches(SectionType type) +{ + const SectionTypeSwitches switches = decomposeSectionType(type); + + selectByData(typeBox, static_cast(switches.base)); + typeSubtitle->setChecked(switches.subtitle); + typeLogo->setChecked(switches.logo); + typeLogoOnly->setChecked(switches.logoOnly); + selectByData(typeListContent, static_cast(switches.content)); +} + +void SectionEditor::onTypeSwitchChanged() +{ + if (loading) + return; + + const SectionType type = composedType(); + + /* + * A heading is either a logo or words with a logo beside them, so the two switches that say + * which cannot both be on. Turning one off rather than disabling the other keeps the switch + * that was just clicked meaning what it said. + */ + if (typeLogoOnly->isChecked() && (typeLogo->isChecked() || typeSubtitle->isChecked())) { + const QSignalBlocker logoBlocker(typeLogo); + const QSignalBlocker subtitleBlocker(typeSubtitle); + typeLogo->setChecked(false); + typeSubtitle->setChecked(false); + } + + applyTypeVisibility(type); + relayoutEntryTable(type); + emitChanged(); +} + +void SectionEditor::relayoutEntryTable(SectionType type) +{ + /* + * The table's columns depend on the type, so changing the type has to take the entries out + * through the old columns and put them back through the new ones. Rebuilding without that + * leaves an empty table for the next read to believe, which is a list of credits thrown away + * by a change of type -- the one thing changing a type is documented never to do. + */ + Section held; + held.type = tableType; + held.rowSubtitles = rowSubtitles->isChecked(); + readEntriesFromTable(&held); + + held.type = type; + rebuildEntryTable(type, held.rowSubtitles); + writeEntriesToTable(held); +} + void SectionEditor::applyTypeVisibility(SectionType type) { + /* + * Every held-back row starts from "as visible as the switch allows". Most are then set again + * below by whatever decides they apply to this type; the handful nothing else manages -- + * where the section sits on the canvas, which is true of every type -- keep this answer, + * which is the whole of how the switch reaches them. + */ + for (QWidget *field : advancedRows) + setRowVisible(field, true); + + /* + * The picker's own switches first: which of them apply is a property of the base type, and + * the rest of this pass reads the type they add up to. + */ + const SectionType base = decomposeSectionType(type).base; + const bool heading = isHeadingBase(base); + const bool list = base == SectionType::TextList; + + setRowVisible(typeSubtitle, heading && !typeLogoOnly->isChecked()); + setRowVisible(typeLogo, heading && !typeLogoOnly->isChecked()); + setRowVisible(typeLogoOnly, heading); + setRowVisible(typeListContent, list); + + const QString help = sectionTypeHelp(type); + typeHelp->setText(help); + setRowVisible(typeHelp, !help.isEmpty()); + + /* + * The group of type-specific settings is named after the type in it. A group called + * "Settings" says nothing; one called "Divider" says what the reader is looking at, and is + * the only label on screen while the group is folded away. + */ + typeSettingsGroup->setTitle( + moduleText("Designer.Group.TypeSettings").arg(QString::fromUtf8(sectionTypeName(type)))); + const bool hasText = sectionUsesText(type); const bool hasLogos = sectionUsesLogos(type); const bool hasEntries = sectionUsesEntries(type); @@ -1522,23 +1807,23 @@ void SectionEditor::applyTypeVisibility(SectionType type) const bool sectionLogo = hasLogos && !hasEntries; const bool logoBesideText = sectionLogo && hasText; - form->setRowVisible(textEdit, singleLineText); + setRowVisible(textEdit, singleLineText); /* * A heading's own subtitle. The list types stack one too, but theirs is the entry table's * second column, so the field belongs to the single-heading shapes alone. */ - form->setRowVisible(subtitleEdit, singleLineText && sectionUsesSubtitles(type)); - form->setRowVisible(logoPath->parentWidget(), sectionLogo); - form->setRowVisible(logoHeight, sectionLogo); + setRowVisible(subtitleEdit, singleLineText && sectionUsesSubtitles(type)); + setRowVisible(logoPath->parentWidget(), sectionLogo); + setRowVisible(logoHeight, sectionLogo); /* * Hidden here and shown again by refreshLogoPlayback, which is the one that knows whether the * artwork moves. Doing it in two steps means every path into this pass -- a type change, a * section switch, a retyped filename -- ends up asking the same question of the same code. */ - form->setRowVisible(logoLoop->parentWidget(), false); - form->setRowVisible(logoAnimatedShadow, false); - form->setRowVisible(logoSide, logoBesideText); - form->setRowVisible(logoGap, logoBesideText); + setRowVisible(logoLoop->parentWidget(), false); + setRowVisible(logoAnimatedShadow, false); + setRowVisible(logoSide, logoBesideText); + setRowVisible(logoGap, logoBesideText); const bool bridged = type == SectionType::Bridged; /* * The fill the row will really be laid out with, not the one the combo happens to hold: an @@ -1566,30 +1851,30 @@ void SectionEditor::applyTypeVisibility(SectionType type) */ const bool emptyBridge = usesBridge && bridgeTypeIsEmpty(bridgeArt); - form->setRowVisible(logoPlacement, logoBesideText); - form->setRowVisible(bridgeType, usesBridge); - form->setRowVisible(bridgeEdit, usesBridge && !drawnArt && !emptyBridge); - form->setRowVisible(bridgeSvgPath->parentWidget(), artFromFile); - form->setRowVisible(bridgeThickness, drawnArt); - form->setRowVisible(bridgeOffset, drawnArt); - form->setRowVisible(bridgeGap, drawnArt); + setRowVisible(logoPlacement, logoBesideText); + setRowVisible(bridgeType, usesBridge); + setRowVisible(bridgeEdit, usesBridge && !drawnArt && !emptyBridge); + setRowVisible(bridgeSvgPath->parentWidget(), artFromFile); + setRowVisible(bridgeThickness, drawnArt); + setRowVisible(bridgeOffset, drawnArt); + setRowVisible(bridgeGap, drawnArt); /* Only a Bridged section reserves the gap between two columns; a logo row spans what is left. */ - form->setRowVisible(bridgeMinGap, emptyBridge && bridged); + setRowVisible(bridgeMinGap, emptyBridge && bridged); /* The built-in tiles are drawn white to be tinted; only a user's file has colours to keep. */ - form->setRowVisible(bridgeTint, artFromFile); - form->setRowVisible(bridgeFill, usesBridge && !emptyBridge); + setRowVisible(bridgeTint, artFromFile); + setRowVisible(bridgeFill, usesBridge && !emptyBridge); /* Column sizing and row placement describe two texts, so they stay with that type. */ - form->setRowVisible(bridgeSizing, bridged); + setRowVisible(bridgeSizing, bridged); /* The split is the tab stop; with Natural sizing the text decides where things land. */ - form->setRowVisible(bridgeSplit, bridged && sizing == BridgeSizing::Split); + setRowVisible(bridgeSplit, bridged && sizing == BridgeSizing::Split); /* * Only a fixed bridge with natural columns can leave a row narrower than the section. * Every other combination fills the width, so there is nothing left to align. */ - form->setRowVisible(bridgeRowAlign, bridged && sizing == BridgeSizing::Natural && fill == BridgeFill::Fixed); + setRowVisible(bridgeRowAlign, bridged && sizing == BridgeSizing::Natural && fill == BridgeFill::Fixed); /* A fixed bridge has nothing to run into the space an empty column would free. */ - form->setRowVisible(bridgeSpanEmpty, bridged && fill != BridgeFill::Fixed); - form->setRowVisible(rowSubtitles, bridged); + setRowVisible(bridgeSpanEmpty, bridged && fill != BridgeFill::Fixed); + setRowVisible(rowSubtitles, bridged); /* * The two subtitle styles show only while the row actually draws subtitles. They are read @@ -1621,28 +1906,34 @@ void SectionEditor::applyTypeVisibility(SectionType type) if (!separateEnds && pieceTabs->currentIndex() == static_cast(PieceSlot::RightEnd)) pieceTabs->setCurrentIndex(static_cast(PieceSlot::LeftEnd)); - form->setRowVisible(dividerMirrorEnds, divider); - form->setRowVisible(dividerArm, divider); - form->setRowVisible(dividerArmSvgPath->parentWidget(), armFromFile); - form->setRowVisible(dividerThickness, divider); - form->setRowVisible(dividerGap, divider); - form->setRowVisible(dividerPieceGap, divider); - form->setRowVisible(dividerRules, divider); + setRowVisible(dividerMirrorEnds, divider); + setRowVisible(dividerArm, divider); + setRowVisible(dividerArmSvgPath->parentWidget(), armFromFile); + setRowVisible(dividerThickness, divider); + setRowVisible(dividerGap, divider); + setRowVisible(dividerPieceGap, divider); + setRowVisible(dividerRules, divider); /* One rule has nothing to be spaced from and nothing to taper against. */ - form->setRowVisible(dividerRuleGap, divider && dividerRules->value() > 1); - form->setRowVisible(dividerRuleInset, divider && dividerRules->value() > 1); + setRowVisible(dividerRuleGap, divider && dividerRules->value() > 1); + setRowVisible(dividerRuleInset, divider && dividerRules->value() > 1); /* * The built-in shapes are drawn white to be tinted, so the flag only means anything once * some slot -- an end, an arm, or a piece of the centre stack -- is pointed at a file. */ const bool dividerFiles = armFromFile || (divider && dividerUsesFile()); - form->setRowVisible(dividerTint, dividerFiles); + setRowVisible(dividerTint, dividerFiles); - form->setRowVisible(columns, hasColumns); - form->setRowVisible(columnGap, hasColumns); - form->setRowVisible(fillOrder, hasColumns); - form->setRowVisible(entryGap, hasEntries); + /* + * The column count is offered for every list, because it is how a list *becomes* one of the + * multi-column types: one column or several is the difference between two of the document's + * types, and asking for a second column is a far plainer way to say that than picking a + * different type out of a list. The two settings that describe a grid follow the count. + */ + setRowVisible(columns, list); + setRowVisible(columnGap, hasColumns); + setRowVisible(fillOrder, hasColumns); + setRowVisible(entryGap, hasEntries); /* * The pair's own two settings apply wherever anything is really stacked, which for a bridged * row is a choice rather than a property of the type -- so this asks about the section @@ -1651,9 +1942,19 @@ void SectionEditor::applyTypeVisibility(SectionType type) */ const bool hasSubtitles = sectionUsesSubtitles(type); const bool stacksSubtitles = hasSubtitles || (bridged && rowSubtitles->isChecked()); - form->setRowVisible(subtitleGap, stacksSubtitles); - form->setRowVisible(subtitleOrder, stacksSubtitles); - form->setRowVisible(spacerHeight, type == SectionType::Spacer); + setRowVisible(subtitleGap, stacksSubtitles); + setRowVisible(subtitleOrder, stacksSubtitles); + setRowVisible(spacerHeight, type == SectionType::Spacer); + + /* + * A sticky block spans the canvas and lets the sections inside it place themselves, so the + * three settings that would narrow it say nothing here -- see the layout, which ignores them + * for this type rather than leaving two nested shares of the width to argue about. + */ + const bool placeable = type != SectionType::StickyBlock; + setRowVisible(marginX, placeable); + setRowVisible(sectionWidth, placeable); + setRowVisible(sectionAlign, placeable); /* * The sticky block's rows. The hold is hidden outright while the block holds for ever, since @@ -1664,17 +1965,17 @@ void SectionEditor::applyTypeVisibility(SectionType type) const auto release = static_cast(stickyRelease->currentData().toInt()); const bool backdrop = sticky && stickyBackdrop->isChecked(); - form->setRowVisible(stickyAnchor, sticky); - form->setRowVisible(stickyCanvasPosition, sticky); - form->setRowVisible(stickyOffset, sticky); - form->setRowVisible(stickyHold, sticky && !stickyHoldForever->isChecked()); - form->setRowVisible(stickyHoldForever, sticky); - form->setRowVisible(stickyRelease, sticky); - form->setRowVisible(stickyForeverWarning, + setRowVisible(stickyAnchor, sticky); + setRowVisible(stickyCanvasPosition, sticky); + setRowVisible(stickyOffset, sticky); + setRowVisible(stickyHold, sticky && !stickyHoldForever->isChecked()); + setRowVisible(stickyHoldForever, sticky); + setRowVisible(stickyRelease, sticky); + setRowVisible(stickyForeverWarning, sticky && stickyHoldForever->isChecked() && stickyReleaseEndsAtHold(release)); - form->setRowVisible(stickyBackdrop, sticky); - form->setRowVisible(stickyBackdropColour, backdrop); - form->setRowVisible(stickyBackdropPadding, backdrop); + setRowVisible(stickyBackdrop, sticky); + setRowVisible(stickyBackdropColour, backdrop); + setRowVisible(stickyBackdropPadding, backdrop); /* * A divider has a style even though it carries no section text: the artwork is inked from @@ -1714,12 +2015,23 @@ void SectionEditor::applyTypeVisibility(SectionType type) * height. With no table at all the trailing spacer takes it instead, which is what keeps the * rows packed at the top rather than spread down the pane. */ + /* + * A group with every row hidden is a heading over nothing, so it goes away with them. Asked + * of the form rather than tracked alongside it: the rows have just been set, and counting + * what is visible cannot disagree with them the way a second list of conditions could. + */ + contentGroup->setVisible(formHasVisibleRow(contentForm)); + typeSettingsGroup->setVisible(formHasVisibleRow(typeSettingsForm)); + placementGroup->setVisible(formHasVisibleRow(placementForm)); + outerLayout->setStretch(trailingStretchIndex, hasEntries || divider ? 0 : 1); } void SectionEditor::rebuildEntryTable(SectionType type, bool rowSubtitles) { const QSignalBlocker blocker(entryTable); + /* What the columns now stand for, so the next relayout can read them back correctly. */ + tableType = type; entryTable->clear(); entryTable->setRowCount(0); @@ -1906,8 +2218,8 @@ void SectionEditor::refreshLogoPlayback() */ const bool animated = sectionHasAnimatedArt(section()); - form->setRowVisible(logoLoop->parentWidget(), animated); - form->setRowVisible(logoAnimatedShadow, animated); + setRowVisible(logoLoop->parentWidget(), animated); + setRowVisible(logoAnimatedShadow, animated); } void SectionEditor::writePiecesToTable(PieceSlot slot, const Section &source) @@ -1933,7 +2245,7 @@ void SectionEditor::writePiecesToTable(PieceSlot slot, const Section &source) if (loading) return; applyPieceRowVisibility(slot, row); - applyTypeVisibility(static_cast(typeBox->currentData().toInt())); + applyTypeVisibility(composedType()); emitChanged(); }; }; @@ -2268,7 +2580,7 @@ void SectionEditor::browseForBridgeSvg() void SectionEditor::browseForEntryLogo() { - const auto type = static_cast(typeBox->currentData().toInt()); + const SectionType type = composedType(); if (!sectionUsesLogos(type) || !sectionUsesEntries(type)) return; @@ -2299,7 +2611,7 @@ void SectionEditor::browseForEntryLogo() void SectionEditor::importCsv() { - const auto type = static_cast(typeBox->currentData().toInt()); + const SectionType type = composedType(); CsvImportDialog dialog(type, rowSubtitles->isChecked(), this); if (dialog.exec() != QDialog::Accepted) diff --git a/src/ui/SectionEditor.hpp b/src/ui/SectionEditor.hpp index ce5101e..c5788b9 100644 --- a/src/ui/SectionEditor.hpp +++ b/src/ui/SectionEditor.hpp @@ -18,9 +18,12 @@ with this program. If not, see #pragma once +#include +#include #include #include "model/CreditsModel.hpp" +#include "ui/CollapsibleGroup.hpp" #include "ui/FontPickerDialog.hpp" #include "ui/StyleControls.hpp" @@ -202,6 +205,41 @@ class SectionEditor : public QWidget { private: void applyTypeVisibility(SectionType type); + + /* + * Adds a row to whichever group is being built, and remembers which form owns it. + * + * The editor is one column of settings split across several collapsible groups, so a row's + * visibility can no longer be set on "the form" -- there are several of them. Recording the + * owner as the row is added is what keeps `setRowVisible` a single call at the fifty-odd + * places that make one, rather than each of them having to know which group its row is in. + */ + void addRow(const QString &label, QWidget *field); + void setRowVisible(QWidget *field, bool visible); + + /* + * Marks a row as one of the settings held back until the reader asks for everything. + * + * What makes this editor daunting is not any one control, it is meeting forty at once -- so + * the ones reached for rarely, or that only make sense once the obvious ones have been tried, + * sit behind a switch. Nothing is *removed* by it: a marked row still comes and goes with the + * type exactly as it did, and is simply not shown while the switch is off. + */ + void markAdvanced(QWidget *field); + + /* + * The section type the picker and its switches add up to, and the reverse: which base type + * and switches stand for a given section type. + * + * The document still carries all twenty types under their own ids -- nothing about + * persistence changes here. What changes is that the reader picks a Title and then says + * whether it has a subtitle and whether it has a logo, instead of choosing between five + * kinds of title in a list of twenty. + */ + SectionType composedType() const; + void showTypeAsSwitches(SectionType type); + /* Shows the switches that apply to the base type, and re-reads what they now compose. */ + void onTypeSwitchChanged(); /* * True when any artwork this section places would animate. Read from file headers rather * than from a decode, so it is cheap enough to ask on every section switch. @@ -217,6 +255,11 @@ class SectionEditor : public QWidget { * widgets have not been written yet. */ void rebuildEntryTable(SectionType type, bool rowSubtitles); + /* + * Re-columns the entry table for a new type, carrying the entries across. The table's shape + * follows the type, so a rebuild without this reads back as a table with nothing in it. + */ + void relayoutEntryTable(SectionType type); void readEntriesFromTable(Section *target) const; void writeEntriesToTable(const Section &source); @@ -251,9 +294,35 @@ class SectionEditor : public QWidget { void emitChanged(); + /* The form rows are being added to at this point in the constructor. */ QFormLayout *form = nullptr; + /* Which form each row's field belongs to, for setRowVisible. */ + QHash rowOwner; + /* The rows held back until the reader asks for everything. */ + QSet advancedRows; + + CollapsibleGroup *contentGroup = nullptr; + CollapsibleGroup *typeSettingsGroup = nullptr; + CollapsibleGroup *placementGroup = nullptr; + QFormLayout *contentForm = nullptr; + QFormLayout *typeSettingsForm = nullptr; + QFormLayout *placementForm = nullptr; QComboBox *typeBox = nullptr; + /* + * The switches that turn a base type into one of the document's own. A heading with a + * subtitle and a logo is a Title, a subtitle and a logo -- three plain answers -- rather than + * "Title w/ Subtitle & Logo" picked out of a list of ten headings. + */ + QCheckBox *typeSubtitle = nullptr; + QCheckBox *typeLogo = nullptr; + QCheckBox *typeLogoOnly = nullptr; + QComboBox *typeListContent = nullptr; + /* One line saying what the selected type is for, under the picker. */ + QLabel *typeHelp = nullptr; + /* Shows the settings that are otherwise held back; see markAdvanced. */ + QCheckBox *showAdvanced = nullptr; + QLineEdit *labelEdit = nullptr; QCheckBox *visibleBox = nullptr; QPlainTextEdit *textEdit = nullptr; @@ -379,6 +448,8 @@ class SectionEditor : public QWidget { StyleEditor *presetOrigin = nullptr; Section current; + /* The type the entry table's columns currently stand for; see relayoutEntryTable. */ + SectionType tableType = SectionType::Title; bool loading = false; }; diff --git a/tests/suites/SectionTypes.cpp b/tests/suites/SectionTypes.cpp index 4e684f1..4d1f9a5 100644 --- a/tests/suites/SectionTypes.cpp +++ b/tests/suites/SectionTypes.cpp @@ -99,6 +99,62 @@ CT_SUITE(section_types, "The type table, its ids and its predicates") } } +CT_SUITE(section_type_switches, "Taking a type apart into switches, and putting it back together") +{ + /* + * The designer's picker offers a handful of base types and a few switches beside them, and + * builds the document's own type from the two. Anything that does not come back out of that + * round trip is a type the reader can no longer choose -- which is why this is asserted over + * the whole table rather than over the shapes that happened to be thought of. + */ + for (SectionType type : allSectionTypes()) { + const Context context(QString::fromUtf8(sectionTypeId(type))); + + const SectionTypeSwitches switches = decomposeSectionType(type); + check(composeSectionType(switches) == type, "a type survives being taken apart and rebuilt"); + + /* A heading is either a logo or words with one beside them, never both at once. */ + check(!(switches.logoOnly && switches.logo), "logo-only and with-a-logo are never both set"); + check(!(switches.logoOnly && switches.subtitle), "nor logo-only and a subtitle"); + + /* The base is one of the types the picker offers, and is its own base. */ + check(decomposeSectionType(switches.base).base == switches.base, "a base type is its own base"); + } + + /* The switches really do reach every shape, rather than the round trip merely being consistent. */ + SectionTypeSwitches heading; + heading.base = SectionType::Header; + heading.subtitle = true; + heading.logo = true; + check(composeSectionType(heading) == SectionType::HeaderWithSubtitleAndLogo, + "a header, a subtitle and a logo compose the type that draws all three"); + + heading.logoOnly = true; + check(composeSectionType(heading) == SectionType::LogoHeader, "and logo-only wins over both"); + + SectionTypeSwitches list; + list.base = SectionType::TextList; + list.content = SectionListContent::Pairs; + list.multiColumn = true; + check(composeSectionType(list) == SectionType::MultiTitleSubtitleList, + "a list of pairs over several columns is the multi-column pair list"); + + list.multiColumn = false; + check(composeSectionType(list) == SectionType::TitleSubtitleList, "and one column is the plain one"); + + /* A type with no switches at all is handed straight back. */ + for (SectionType type : {SectionType::Bridged, SectionType::SectionDivider, SectionType::Spacer, + SectionType::StickyBlock}) { + SectionTypeSwitches bare; + bare.base = type; + /* Set deliberately: a type that reads a switch it has no use for would fail here. */ + bare.subtitle = true; + bare.logo = true; + bare.multiColumn = true; + check(composeSectionType(bare) == type, "a type with no switches ignores them"); + } +} + CT_SUITE(section_defaults, "What a freshly added section of each type carries") { for (SectionType type : allSectionTypes()) { From 06882f6aabca5ef55aa279abe4ef442ee9faf6bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:51:19 +0000 Subject: [PATCH 5/7] Draw a block's animated artwork as a still, and write the doc up A sticky block is drawn as one quad, and the strip's trick for animation is to leave a hole and draw the artwork over it. Inside a block there is nowhere to put a second hole, so an animated logo in one was a hole with nothing over it: not drawn at all. Its children are now laid out without the animation cache, which is the renderer's own way of asking for every logo as a still. ARCHITECTURE.md picks up the four features and the two limitations that come with the block. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SctZkVkj2RxyH7oJUvH6wQ --- ARCHITECTURE.md | 164 +++++++++++++++++++++++++++++++--- src/render/StripRenderer.cpp | 15 +++- tests/suites/StickyBlocks.cpp | 24 +++++ 3 files changed, 185 insertions(+), 18 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3b19b2a..4e25e2a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -81,7 +81,7 @@ A `Document` is a canvas (`width`, `height`, `background`), playback settings `scrollPosition`), an `EndingActionConfig`, a list of `StylePreset`s, and an ordered list of `Section`s. -`Section` is a single struct covering all nineteen types rather than a class hierarchy. The +`Section` is a single struct covering all twenty types rather than a class hierarchy. The fields a given type actually uses are described by five predicates — `sectionUsesText/Logos/Entries/Columns/Subtitles` — which drive both the layout switch and the editor's field visibility, plus `sectionUsesSecondaryText`, derived from two of them rather than @@ -116,8 +116,9 @@ side of the frame while still keeping a margin's worth of clear space off that s | `TitleSubtitleList` | entry list of stacked text pairs | e.g. a position over the name that holds it; see below | | `LogoList` | entry list of logos, one column | | | `MultiTextList`, `MultiLogoList`, `MultiTitleSubtitleList` | entry list over `columns` columns | `fillAcross` picks row-major vs column-major | -| `SectionDivider` | no entries; a `dividerCentre` stack of its own | an ornamental rule composed from a cap, an arm and a centre; see below | +| `SectionDivider` | no entries; three piece stacks of its own | an ornamental rule composed from two ends, an arm and a middle; see below | | `Spacer` | nothing | a blank run of `spacerHeight` px | +| `StickyBlock` | `children`: whole sections of its own | pins to the canvas while the roll runs past behind it; see below | ### Logo rows @@ -187,7 +188,14 @@ the bridge is made of and how the width is carved up between them. **`bridgeType`** is what the bridge is drawn from. `Text` is the original — a string set in the section's font — and every other type is **vector art**: a small SVG tile laid across the gap. -See *Bridge artwork* below. +See *Bridge artwork* below. `None` draws nothing at all: two columns of text with plain +space between them is a layout people ask for, and expressing it as an empty bridge leaves every +other setting on the row meaning exactly what it meant. It costs one number, `bridgeMinGap`, +because an empty bridge has no natural width of its own to keep the two texts off each other with +under `Natural` sizing — and it is always laid out as `Fixed`, through `effectiveBridgeFill`, +since three ways of filling a gap with nothing differ only in how the text columns come out. The +designer hides the fill row to match: a control named after something visible must not be doing +something invisible. **`bridgeFill`** is what the bridge does with a gap wider than one copy of itself: @@ -240,6 +248,16 @@ row — and would break the invariant above. Under `Natural` an empty side alrea zero, so the flag only really bites under `Split`, where the column is reserved whether or not anything is in it. +**`rowSubtitles`** stacks a second line under each side of every row, from the entry's own +`subtitle` and `secondarySubtitle`, in a style each: the two sides of a bridged row are already +styled apart, and a subtitle that could not follow the line above it would be the one part of the +row unable to. Both sides go through `layoutTitleSubtitle` whether or not they carry one, so a row +without subtitles is measured and drawn by the very code that always drew it — an empty line +takes no height and no gap, so the pair collapses to the single line a bridged row has always been. +A column is sized from the wider of its two lines, and the leader hangs off whichever line ends up +**on top**, the rule a bridged logo row already follows, so adding a subtitle under a name leaves +the leader exactly where it was. + Finally, the three parts share a **baseline** rather than a top edge, anchored on whichever reaches lowest so nothing climbs into the row above. That is what keeps a leader running through the middle of the text when the two sides are set at different sizes; when they @@ -346,20 +364,33 @@ than there are rows in the library — and adding a shape adds it to every divid it rather than to one. `model/DividerArt.{hpp,cpp}` is a table on the same pattern as `BridgeArt`, with one field the -bridge table has no use for: **`roles`**, a mask of which slots a shape may be picked for. A -diamond is a perfectly good end cap *and* a perfectly good centrepiece, and saying so once beats -a second Diamond in a second table that has to be kept looking like the first. The pickers are -built by filtering the table on the slot they serve, so a shape is offered wherever it belongs -without anything enumerating the combinations. +bridge table has no use for: **`roles`**, a mask of which slots a shape may be picked for. There +are two roles rather than three, because an end and a middle turned out to be the same slot: both +are a stack of pieces, so a shape that suits one suits the other, and keeping them apart only meant +two lists that had to be argued about shape by shape. An arm stays its own role — it is the rule +itself, tiled along a span, which is a different job from being drawn once at its own size. + +**An end is a stack, exactly as the middle is.** `dividerCap` and `dividerEndCap` are +`QVector` like `dividerCentre`, so a diamond outside an arrowhead, or `MMXXVI` set +against the rule, is an end as readily as it is a centre. Nothing about it is a second +implementation: one helper measures a run of pieces and one places it, and the three stacks differ +only in where they are put and whether they are mirrored. A document that wrote an end as a single +shape — which is every document there is — loads as the one-piece stack that draws the same +divider, from keys of its own: a key that is a string in one document and an array in the next is a +trap for every reader of either. Two things about the geometry are worth naming, because both are what let one number size a whole divider: -**A cap is authored pointing outward along -x, and the right-hand end is that same tile +**A cap is authored pointing outward along -x, and the right-hand end is that same stack mirrored.** Mirroring is a painter transform on the artwork rather than a second tile, so a cap -cannot come out subtly different at the two ends of the same rule. `dividerMirrorEnds` is on by +cannot come out subtly different at the two ends of the same rule; the stack's own order reverses +with it, so a lopsided end comes out symmetric. A word and a picture are deliberately *not* +flipped, because mirrored type is a mistake rather than a design. `dividerMirrorEnds` is on by default and true of every ornamental rule that is not an arrow pointing somewhere; switching it -off reveals `dividerEndCap`, which is *kept* either way so the toggle is non-destructive. The +off reveals `dividerEndCap`, which is *kept* either way so the toggle is non-destructive. Note that +it says the two ends are the same *list*, not that the right-hand one is drawn unflipped: an end is +always a mirror image of the way it is written, whichever list it came from. The same mirror applies to the right-hand **arm**, unconditionally: a taper running from a hairline up to full thickness, or a rule ticked at one edge of each tile, would otherwise point the same way on both sides and leave the divider lopsided. Mirroring a symmetric tile costs a transform @@ -719,6 +750,64 @@ restarts them on a loop wrap or a restart hotkey. The frame showing at a given p `logoFrameAt`, shared by the source and the designer's preview so the two cannot disagree about what a speed multiplier or a play-once means. +### Sticky ending blocks + +A roll scrolls past. What it could not do is hold: a closing card that stays on the frame while the +last of the credits runs up behind it, and then ends the roll when it has been up long enough. + +`StickyBlock` is a section that holds other sections (`children`, one level deep — a block inside a +block is a second kind of position with a second set of rules, and the loader drops any that turns +up). It is laid into the roll like any other section and takes exactly the room its content would +have taken inline, so nothing above or below it moves for the feature, and it travels up with the +roll until its slot reaches the anchor. Then it detaches. + +**It is not painted into the strip.** The strip is one tall picture that scrolls, and a thing that +stops scrolling while the rest of it carries on cannot be part of that picture — so the slot is +left empty and the block is carried out on the `Strip` as a `StickyBlockPlacement`: a picture of its +own, drawn over the tiles by whoever composites. The same bargain an animated logo strikes, and for +the same reason. The empty slot goes on scrolling after the block has left it, which is what keeps +the content either side of it where it was. + +**The pin is a pair of points.** `stickyAnchor` says which part of the block — its top edge, its +middle, its bottom — and `stickyCanvasPosition` where down the canvas that part lands, with +`stickyOffset` as a nudge in pixels. "The middle of the block, halfway down the frame" needs both +halves, and a single number could only ever express one of them. The share is of the canvas height +rather than a pixel offset, so a roll pins where it was meant to after a canvas resize. + +**What happens at the end of the hold is a setting**, because it answers two independent questions: +does the block leave, and what counts as the end of the roll. `EndAtHold` stays put and ends the +roll; `ResumeThenEnd` climbs off the top and lets the roll end as it always did; `ResumeEndAtHold` +does both. `stickyHoldForever` holds until something else stops the roll, and the designer says as +much beside the release rather than leaving a roll with no end to be discovered on air. + +`advance` keeps its own job. A block that still has something to do sets `stickyPending`, which is +the whole of what the scroll loop knows about blocks: the strip clearing does not finish a roll +while it is set, and the block finishes the roll itself through `finishRoll` when its hold is over. +Where a block is *drawn* is decided separately, by `stickyBlockTop`, as the lower of its natural +position and its pinned one — one expression rather than two branches, which is what makes a roll +parked in manual scroll show every block where it belongs with none of the timing running. + +**The backdrop is painted into the block's own picture** rather than drawn as a quad behind it. +libobs draws solids and textures from different effects, and starting a second one inside the pass +that is drawing the strip is not something to do for a rectangle the rasteriser can fill for +nothing at rebuild time. The picture is grown by a margin at each end for it, and for whatever the +children paint outside their own boxes — unlike the strip, there is no neighbouring tile here to +catch a shadow. + +A block spans the canvas: `sectionWidth`, `sectionAlign` and `marginX` are ignored for it, because +what it holds is whole sections and each of them carries a box of its own. Two nested shares of the +width would be two settings for one thing, and the inner one can already say everything the outer +one could. + +In the designer a block's children are indented under it in the section list, and a drag decides +its container by the row it lands under: drop something beneath a block or one of its rows and it +joins the block, drop it anywhere else and it is top-level. That is one gesture for putting a +section in and taking it back out, rather than a second one to learn. A list that is no longer flat +means every operation on the selected row goes through a `SectionPath` rather than an index. The +preview draws a block back into its slot rather than where it will pin: the pane is the roll end to +end, and a card floating over an unrelated part of it with a hole left where it belongs would agree +with nothing else on screen. + **Scrolling.** The strip's top edge sits one canvas-height below the top of the frame at offset 0 and travels upward, so the roll enters from the bottom. Total travel is `canvasHeight + stripHeight`; `leadIn`/`leadOut` are baked into the strip itself as blank @@ -879,7 +968,36 @@ the one way back out. The editor keeps one widget set and hides the rows that do not apply to the selected type (`QFormLayout::setRowVisible`, Qt 6.4+) rather than rebuilding, which keeps focus and scroll -position stable while clicking down the section list. A trailing spacer takes whatever height +position stable while clicking down the section list. + +**The picker asks three easy questions rather than one hard one.** Twenty types in one list is +every shape the roll can hold and no way to find the one wanted, because they are not twenty +different things: they are two headings, a list, a bridged row and three others, crossed with +whether a subtitle is stacked under it, whether a logo sits beside it, what a list holds and how +many columns it runs over. So the picker offers seven base types with those switches beside it, and +`composeSectionType`/`decomposeSectionType` map between the two. Both live in the model, because +taking a type apart is a property of the type table rather than of this window, and because a +mapping that has to be exactly reversible is worth a test that needs no window on screen. Nothing +about persistence changes: the document still carries all twenty ids. + +**The rows are gathered into named groups that fold away** — what the section says, how this kind +of section is put together, where it sits — with the middle one titled after the type in it, since +"Bridge settings" and "Divider settings" are never on screen at once. A `CollapsibleGroup` rather +than a checkable `QGroupBox`: a checkbox on a group reads as switching the group *off*, which is +what the checkable groups already in this editor mean. A group whose every row is hidden goes away +with them, which is asked of the layout rather than of the widgets in it — a child of a window that +has not been shown yet reads as hidden whether or not anything hid it. + +**The settings reached for rarely sit behind one switch.** Nothing is switched off by it: a +held-back row still comes and goes with the type exactly as it did, and `setRowVisible` simply ands +the two together. The section box is deliberately not among them — it is the setting that places a +section, and hiding it would leave `marginX` looking like the only way to, which is the confusion +the box was added to end. + +Because the entry table's columns follow the type, changing the type takes the entries out through +the old columns and puts them back through the new ones (`relayoutEntryTable`). Rebuilding without +that left an empty table for the next read to believe, which threw a cast list away on a change of +type — the one thing changing a type is documented never to do. A trailing spacer takes whatever height is left over: a `QVBoxLayout` with nothing to give its slack to shares it out between the items it has, which spread a short type's handful of rows down the pane with gaps between them. The entry table is the one thing worth growing, so it takes the slack instead whenever the selected @@ -1241,13 +1359,31 @@ same reason: reporting it would send the user after a font nothing uses. different settings per entry is honoured; the editor writes one set of settings to every logo in a section, because a loop switch on each of twelve sponsor cells is a column of checkboxes nobody wants to fill in. -7. **A library rename is followed but never forced.** A document that already has a preset of +7. **A sticky block is one level deep, and its logos do not animate.** A block cannot hold another + block. Its children are rasterised into the block's own picture, which is drawn as a single + quad, so an animated logo inside one shows its first frame — the strip's hole-and-overlay + trick has nowhere to put a second hole. Both are deliberate: pinning something to something + already pinned is a second set of rules, and a card that holds still is not where a moving + logo is missed. +8. **A library rename is followed but never forced.** A document that already has a preset of its own under the new name keeps its link under the old one — merging two presets is not a rename — and migrates by itself if the clash is ever resolved. The manager says so when it happens. ### Addressed since the first cut +- A Sticky Ending Block: a section holding sections of its own that pins itself to a place on the + canvas while the rest of the roll scrolls past behind it, holds, and then either stays put and + ends the roll or carries on up and off. +- A bridge can be nothing at all — two columns of text held apart by a plain gap — and a bridged + row can carry a subtitle under each of its two sides, each in a style of its own. +- A divider's ends are the same stack of pieces its middle has always been, from the same library + of shapes: an arrowhead can break a rule as well as cap one, and a word can cap one as well as + break one. +- The section editor asks for a base type and a few switches rather than one of twenty types, and + its rows are gathered into groups that fold away, with the rarely-used ones behind a switch. +- Deleting a section asks first. + - Animated logos: GIF, APNG and animated WebP through Qt, drawn as their own quads over a hole the strip leaves for them, with per-logo loop, start-on-entry, speed and shadow-follows-animation settings. @@ -1340,7 +1476,7 @@ that the leader hangs a fixed distance below the top line of the block, and that asserted now, with the two regimes checked separately underneath it. Renderer and parser changes before the harness existed were validated with the same approach in -an ad-hoc form, covering the `obs_data` round trip for all nineteen section types, measure/render +an ad-hoc form, covering the `obs_data` round trip for all twenty section types, measure/render agreement, tile contiguity and the tile-height cap, alpha format, hidden-section handling, and the CSV parser's quoting/line-ending/delimiter-detection cases. diff --git a/src/render/StripRenderer.cpp b/src/render/StripRenderer.cpp index cf8ba9b..ee9d359 100644 --- a/src/render/StripRenderer.cpp +++ b/src/render/StripRenderer.cpp @@ -1562,9 +1562,16 @@ int layoutSection(QPainter *painter, const Section §ion, const Document &doc * after the block has detached from it. What it does *not* do is draw into the strip: * with a painter this leaves the slot empty, and the block itself is handed to the * compositor as a picture of its own. + * + * Its children are laid out without the animation cache, which is the documented way to + * ask for every logo as a still: the strip's hole-and-overlay trick has nowhere to put a + * second hole inside a picture that is itself drawn as one quad, and a logo left as a + * hole with nothing over it would simply not be drawn at all. */ - const int height = layoutStickyChildren(nullptr, section, document, logos, bridges, dividers, y, - boxes, animated); + const LogoSource stills{logos.stills, nullptr}; + + const int height = layoutStickyChildren(nullptr, section, document, stills, bridges, dividers, y, + boxes, nullptr); record(LayoutBox::Kind::Sticky, QRectF(0, y, document.width, height)); if (sticky && height > 0) { @@ -1622,8 +1629,8 @@ int layoutSection(QPainter *painter, const Section §ion, const Document &doc section.stickyBackdropColor); } - layoutStickyChildren(&blockPainter, section, document, logos, bridges, dividers, y, nullptr, - nullptr); + layoutStickyChildren(&blockPainter, section, document, stills, bridges, dividers, y, + nullptr, nullptr); blockPainter.end(); /* Straight alpha, for the same reason the tiles are. */ diff --git a/tests/suites/StickyBlocks.cpp b/tests/suites/StickyBlocks.cpp index 379615a..f28ee18 100644 --- a/tests/suites/StickyBlocks.cpp +++ b/tests/suites/StickyBlocks.cpp @@ -230,6 +230,30 @@ CT_SUITE(sticky_children, "What a block may hold") check(loaded.children.front().type == SectionType::Title, "and everything else survives"); } +CT_SUITE(sticky_animated_logo, "Animated artwork inside a block") +{ + /* + * A block is drawn as one quad, so there is nowhere inside it to leave a second hole for an + * animation to be drawn into. Its children are therefore laid out without the animation + * cache, which is the renderer's own way of asking for every logo as a still -- and the + * check that matters is that something is drawn at all, since a logo left as a hole with + * nothing over it is a block with a gap in it. + */ + Section block = unpadded(SectionType::StickyBlock); + block.children.clear(); + + Section logos = unpadded(SectionType::LogoList); + withAnimatedLogo(logos); + block.children.push_back(logos); + + const Strip strip = renderAnimatedStrip(documentWith(block)); + checkEq(strip.stickyBlocks.size(), 1, "the block is carried"); + check(strip.animatedLogos.isEmpty(), "and its artwork is not reported as a hole in the strip"); + + if (!strip.stickyBlocks.isEmpty()) + check(!inkOf(strip.stickyBlocks.first().image).isEmpty(), "the artwork is drawn into the block"); +} + CT_SUITE(sticky_roll, "A block among the sections of a whole roll") { /* From 4780a904d03d2e3e6df9425dae33110dd2cc34db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:53:19 +0000 Subject: [PATCH 6/7] Tell the README what the roll can do now The sticky ending block, the gap and the subtitles a bridged row can carry, the divider's ends being what its middle is, and a designer that asks for a kind of section and then a couple of plain questions about it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SctZkVkj2RxyH7oJUvH6wQ --- README.md | 56 +++++++++++++++++++++++++++----------- src/model/CreditsModel.cpp | 20 ++++++++++---- src/ui/PreviewWidget.cpp | 10 ++++++- 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a82afd6..6fdd4a2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ fires once the roll clears the screen. ## What it does Add a **Credits Marquee** source to a scene, open **Credits Designer**, and build the roll -out of sections: +out of sections. The designer asks for a kind of section and then a couple of plain questions +about it — a heading, with a subtitle, with a logo; a list of lines, pairs or images over so many +columns — which between them cover every shape in the table below: | Section type | What it is | |---|---| @@ -26,8 +28,9 @@ out of sections: | Multi-List of Text | Names across a configurable number of columns | | Title and Subtitle Multi-List | The same stacked pairs, across a configurable number of columns | | Multi-List of Logos | Sponsor logos across a configurable number of columns | -| Section Divider | An ornamental rule between sections, composed from end caps, a rule and a centrepiece | +| Section Divider | An ornamental rule between sections, composed from its two ends, the rule between them, and whatever sits in the middle | | Spacer | A blank run, for pacing | +| Sticky Ending Block | Sections that hold still on the frame while the rest of the roll scrolls past behind them — a closing card that stays up, and can be the end of the roll | A **title and subtitle** list is the other way to set what a bridged row sets on one line: the position on top and the name under it, each with its own font, size, weight, colour and @@ -46,13 +49,18 @@ beside the whole pair — centred against both lines together, not just the titl it, sit against the section edge, or bridge across to it, with the leader running along the baseline of whichever line ends up on top. +Either side of a bridged row can carry a **subtitle** of its own — a note under the role on the +left, a company under the name on the right — each in a style of its own, and drawn only where you +put something. The leader stays on the top line of the row, so adding one leaves it where it was. + In a bridged row the **bridge** joining the two texts is drawn from artwork rather than from characters, so a leader is a real shape at a thickness you set instead of whatever full stops your font happens to draw: pick dots, dashes, diamonds, a line or a double rule, or point it at an SVG of your own. It picks up the section's own colour, gradient, outline and shadow, so it belongs to the row rather than sitting on it, and it can be lifted off the baseline to run a rule through the middle of the text. Plain text is still one of the styles, for when a word — -`and`, `with` — is what belongs in the gap. +`and`, `with` — is what belongs in the gap, and so is nothing at all, for two columns held apart +by a plain gap you set the width of. Bridged rows are fully placeable on top of that: the bridge can be drawn once, repeated, or spread to span whatever gap the two texts leave; the columns can be fixed (so the leader starts @@ -61,19 +69,21 @@ row can hug either edge or sit centred; and a row with only one side filled in c leader out to the far edge, for a heading inside an otherwise bridged list. A **section divider** is the ornamental rule that separates one part of a roll from the next, -and it is *composed* rather than picked from a list of finished pictures: an end cap, a rule -running inward from it, and a centre — then the same again mirrored, so the two ends of a rule -cannot drift apart while you work on it. Each of the three is drawn from a small library of -vector shapes — arrowheads, spearpoints, teardrops, scrolled ends and deco steps for the ends; -plain, double, tapered, dotted, dashed, diamond-chained and ticked rules; diamonds, nested -diamonds, four- and five-point stars, hearts, scrolls, filigree curls and deco interlocks for -the middle — and any of the three can instead be an SVG of your own. Two dozen shapes across -three slots is a great many more dividers than there are shapes. - -The middle is a *list* rather than a single choice, because the figures that turn up there are -compounds: a diamond with a dot either side of it, `PART II` between two curls, a monogram -between a pair of scrolls. Pieces can be ornaments, a word set in the section's own font, or an -image, in whatever order you put them; leave the list empty and you get an unbroken rule. Two +and it is *composed* rather than picked from a list of finished pictures: an end, a rule +running inward from it, and a middle — then the same again mirrored, so the two ends of a rule +cannot drift apart while you work on it. The ends and the middle are the same kind of thing and +draw on the same library of vector shapes — arrowheads, spearpoints, teardrops, scrolled ends, +deco steps and chevrons; diamonds, nested diamonds, four- and five-point stars, dots, hearts, +scrolls, filigree curls and deco interlocks — so an arrowhead can break a rule as readily as it +caps one, and any of them can instead be an SVG of your own. The rule between them has a library +of its own: plain, double, tapered, dotted, dashed, diamond-chained and ticked. + +Each end, and the middle, is a *list* rather than a single choice, because the figures that turn +up there are compounds: a diamond with a dot either side of it, `PART II` between two curls, a +monogram between a pair of scrolls, `MMXXVI` set against the end of the rule. Pieces can be +ornaments, a word set in the section's own font, or an image, each at a size of its own and in +whatever order you put them; leave a list empty and you get an end with nothing on it, or an +unbroken rule. Two or three rules can run in parallel with a single ornament breaking all of them, and they can be tapered so the stack forms a wedge. One thickness sizes the whole thing — every shape knows its own proportion to the rule it belongs to, so an arrowhead stays an arrowhead when a divider is @@ -127,6 +137,20 @@ preview shows the first frame until you press **Play animations**, so the pane h a roll is being written. Artwork is decoded whole and up front, which puts a ceiling on it: past 30 seconds a logo plays only its first 30, and the designer says so. +A **sticky ending block** is a run of sections that stops scrolling. It travels up the frame like +any other part of the roll, and when it reaches the place you pinned it to, it stays there while +the rest of the credits carries on past behind it. You pick which part of the block is pinned — +its top edge, its middle, its bottom — and where down the frame that lands, so "centred in the +frame" and "top edge a third of the way down" are both one setting rather than a number to work +out. It can hold for a set time or until something else stops the roll, and when the hold ends it +either stays put and ends the roll, carries on up and off the top, or does both at once. Turn on +its panel and the credits running past underneath will not read through its lettering. + +It holds whole sections, so a closing card is a title, a divider and a logo the way anything else +is; drag them under the block in the section list and they are inside it. Nothing above or below +moves for it, and you can have as many as you like — the same mechanism gives you a chapter +heading that holds while the section under it scrolls by. + The source properties cover canvas size, background colour, scroll speed, lead-in and lead-out padding, start behaviour, looping, and the ending action. diff --git a/src/model/CreditsModel.cpp b/src/model/CreditsModel.cpp index 9d8a22d..30de27c 100644 --- a/src/model/CreditsModel.cpp +++ b/src/model/CreditsModel.cpp @@ -1011,13 +1011,21 @@ void Section::save(obs_data_t *data) const /* * A sticky block's children are sections, saved by the very same call that saved this one. * Only one level deep can ever be written, because only one level can ever be held. + * + * Written only when there are any: every other section in every document would otherwise + * carry an empty array it has no use for, in settings that are rewritten on every save and + * read back on every load. */ - saveArray( - data, "children", static_cast(children.size()), - [](obs_data_t *item, int index, const void *context) { - static_cast *>(context)->at(static_cast(index)).save(item); - }, - &children); + if (!children.empty()) { + saveArray( + data, "children", static_cast(children.size()), + [](obs_data_t *item, int index, const void *context) { + static_cast *>(context) + ->at(static_cast(index)) + .save(item); + }, + &children); + } } void Section::load(obs_data_t *data) diff --git a/src/ui/PreviewWidget.cpp b/src/ui/PreviewWidget.cpp index e228694..28fb6aa 100644 --- a/src/ui/PreviewWidget.cpp +++ b/src/ui/PreviewWidget.cpp @@ -66,6 +66,8 @@ QColor layoutBoxColour(LayoutBox::Kind kind) return QColor(235, 225, 110); case LayoutBox::Kind::Divider: return QColor(180, 140, 255); + case LayoutBox::Kind::Sticky: + return QColor(255, 90, 90); case LayoutBox::Kind::Text: default: return QColor(255, 120, 200); @@ -406,7 +408,12 @@ void PreviewWidget::paintLayoutBoxes(QPainter &painter, const QRect &canvasColum colour.setAlpha(dim ? 60 : 230); QPen boxPen(colour, 1); - if (kind == LayoutBox::Kind::Section) + /* + * A sticky block's slot is dashed for the reason a section box is: it is a + * bound rather than something drawn -- and doubly so here, since the block is + * drawn somewhere else entirely the moment it pins. + */ + if (kind == LayoutBox::Kind::Section || kind == LayoutBox::Kind::Sticky) boxPen.setStyle(Qt::DashLine); else if (kind == LayoutBox::Kind::Content) boxPen.setStyle(Qt::DotLine); @@ -423,6 +430,7 @@ void PreviewWidget::paintLayoutBoxes(QPainter &painter, const QRect &canvasColum draw(LayoutBox::Kind::Logo); draw(LayoutBox::Kind::Bridge); draw(LayoutBox::Kind::Divider); + draw(LayoutBox::Kind::Sticky); painter.restore(); } From f96fda76854dc1871cdd78e5e2d21e2388284d40 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 20:05:46 +0000 Subject: [PATCH 7/7] Put the preview's two overlay comments back over their own declarations A patch inserted the sticky block's helper between a comment and the declaration it described, leaving the animated-logo comment orphaned above the wrong one and the declaration under it indented into the middle of nowhere. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SctZkVkj2RxyH7oJUvH6wQ --- src/ui/PreviewWidget.hpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ui/PreviewWidget.hpp b/src/ui/PreviewWidget.hpp index 52bf38e..c74620d 100644 --- a/src/ui/PreviewWidget.hpp +++ b/src/ui/PreviewWidget.hpp @@ -89,15 +89,15 @@ class PreviewWidget : public QWidget { void paintLayoutBoxes(QPainter &painter, const QRect &canvasColumn, qreal scale) const; /* - * Draws the animated logos into the holes the strip left for them. Always called, playing or - * not: a hole with nothing drawn into it is a missing logo, not a paused one. - */ -/* * Draws the sticky blocks into the slots the strip left for them, so the preview shows the * roll as it is written rather than as it will be pinned. */ void paintStickyBlocks(QPainter &painter, const QRect &canvasColumn, qreal scale) const; - void paintAnimatedLogos(QPainter &painter, const QRect &canvasColumn, qreal scale) const; + /* + * Draws the animated logos into the holes the strip left for them. Always called, playing or + * not: a hole with nothing drawn into it is a missing logo, not a paused one. + */ + void paintAnimatedLogos(QPainter &painter, const QRect &canvasColumn, qreal scale) const; Strip strip; LayoutBoxes layoutBoxes;