Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -251,21 +251,11 @@ class HeadingNode extends BlockInlineContainerNode {
}
}

enum ListStyle { ordered, unordered }
sealed class ListNode extends BlockContentNode {
const ListNode(this.items, {super.debugHtmlNode});

class ListNode extends BlockContentNode {
const ListNode(this.style, this.items, {super.debugHtmlNode});

final ListStyle style;
final List<List<BlockContentNode>> items;

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(FlagProperty('ordered', value: style == ListStyle.ordered,
ifTrue: 'ordered', ifFalse: 'unordered'));
}

@override
List<DiagnosticsNode> debugDescribeChildren() {
return items
Expand All @@ -275,6 +265,22 @@ class ListNode extends BlockContentNode {
}
}

class UnorderedListNode extends ListNode {
const UnorderedListNode(super.items, {super.debugHtmlNode});
}

class OrderedListNode extends ListNode {
const OrderedListNode(super.items, {required this.start, super.debugHtmlNode});

final int start;

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('start', start));
}
}

class QuotationNode extends BlockContentNode {
const QuotationNode(this.nodes, {super.debugHtmlNode});

Expand Down Expand Up @@ -1108,12 +1114,7 @@ class _ZulipContentParser {
}

BlockContentNode parseListNode(dom.Element element) {
ListStyle? listStyle;
switch (element.localName) {
case 'ol': listStyle = ListStyle.ordered; break;
case 'ul': listStyle = ListStyle.unordered; break;
}
assert(listStyle != null);
Comment on lines -1112 to -1116
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should still have an assert for this method's expectations on element.localName.

If you look at other methods on this class, you can see that that's the pattern they follow: any facts they assume (which their callers are expected to ensure), they assert at the top. And then down at the if/else below, this assumption is essential for understanding why the logic makes sense, so it's important to make explicit within the method.

(See also #1329 (comment) — this is why I said there that the references to ListStyle could switch to referring to element.localName directly, instead of saying they'd be deleted.)

assert(element.localName == 'ol' || element.localName == 'ul');
assert(element.className.isEmpty);

final debugHtmlNode = kDebugMode ? element : null;
Expand All @@ -1126,7 +1127,15 @@ class _ZulipContentParser {
items.add(parseImplicitParagraphBlockContentList(item.nodes));
}

return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode);
if (element.localName == 'ol') {
final startAttr = element.attributes['start'];
final start = startAttr == null ? 1
: int.tryParse(startAttr, radix: 10);
if (start == null) return UnimplementedBlockContentNode(htmlNode: element);
return OrderedListNode(items, start: start, debugHtmlNode: debugHtmlNode);
} else {
return UnorderedListNode(items, debugHtmlNode: debugHtmlNode);
}
}

BlockContentNode parseSpoilerNode(dom.Element divElement) {
Expand Down
46 changes: 18 additions & 28 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -492,45 +492,35 @@ class ListNodeWidget extends StatelessWidget {
final items = List.generate(node.items.length, (index) {
final item = node.items[index];
String marker;
switch (node.style) {
switch (node) {
// TODO(#161): different unordered marker styles at different levels of nesting
// see:
// https://html.spec.whatwg.org/multipage/rendering.html#lists
// https://www.w3.org/TR/css-counter-styles-3/#simple-symbolic
// TODO proper alignment of unordered marker; should be "• ", one space,
// but that comes out too close to item; not sure what's fixing that
// in a browser
case ListStyle.unordered: marker = "• "; break;
// TODO(#59) ordered lists starting not at 1
case ListStyle.ordered: marker = "${index+1}. "; break;
case UnorderedListNode(): marker = "• "; break;
case OrderedListNode(:final start): marker = "${start + index}. "; break;
}
return ListItemWidget(marker: marker, nodes: item);
return TableRow(children: [
Align(
alignment: AlignmentDirectional.topEnd,
child: Text(marker)),
BlockContentList(nodes: item),
]);
});

return Padding(
padding: const EdgeInsets.only(top: 2, bottom: 5),
child: Column(children: items));
}
}

class ListItemWidget extends StatelessWidget {
const ListItemWidget({super.key, required this.marker, required this.nodes});

final String marker;
final List<BlockContentNode> nodes;

@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: localizedTextBaseline(context),
children: [
SizedBox(
width: 20, // TODO handle long numbers in <ol>, like https://github.com/zulip/zulip/pull/25063
child: Align(
alignment: AlignmentDirectional.topEnd, child: Text(marker))),
Expanded(child: BlockContentList(nodes: nodes)),
]);
child: Table(
defaultVerticalAlignment: TableCellVerticalAlignment.baseline,
textBaseline: localizedTextBaseline(context),
columnWidths: const <int, TableColumnWidth>{
0: IntrinsicColumnWidth(),
1: FlexColumnWidth(),
},
children: items));
}
}

Expand Down
15 changes: 14 additions & 1 deletion test/flutter_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ library;
import 'package:checks/checks.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';

extension PaintChecks on Subject<Paint> {
Subject<Shader?> get shader => has((x) => x.shader, 'shader');
}

extension RectChecks on Subject<Rect> {
Subject<double> get left => has((d) => d.left, 'left');
Subject<double> get top => has((d) => d.top, 'top');
Subject<double> get right => has((d) => d.right, 'right');
Subject<double> get bottom => has((d) => d.bottom, 'bottom');

Subject<double> get width => has((d) => d.width, 'width');
Subject<double> get height => has((d) => d.height, 'height');
// TODO others
}

Expand All @@ -36,6 +40,10 @@ extension GlobalKeyChecks<T extends State<StatefulWidget>> on Subject<GlobalKey<
Subject<T?> get currentState => has((k) => k.currentState, 'currentState');
}

extension RenderBoxChecks on Subject<RenderBox> {
Subject<Size> get size => has((x) => x.size, 'size');
}

extension IconChecks on Subject<Icon> {
Subject<IconData?> get icon => has((i) => i.icon, 'icon');
Subject<Color?> get color => has((i) => i.color, 'color');
Expand Down Expand Up @@ -130,6 +138,11 @@ extension InlineSpanChecks on Subject<InlineSpan> {
Subject<TextStyle?> get style => has((x) => x.style, 'style');
}

extension RenderParagraphChecks on Subject<RenderParagraph> {
Subject<InlineSpan> get text => has((x) => x.text, 'text');
Subject<bool> get didExceedMaxLines => has((x) => x.didExceedMaxLines, 'didExceedMaxLines');
}

extension SizeChecks on Subject<Size> {
Subject<double> get width => has((x) => x.width, 'width');
Subject<double> get height => has((x) => x.height, 'height');
Expand Down
47 changes: 35 additions & 12 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,26 @@ class ContentExample {
url: '/#narrow/channel/378-api-design/topic/notation.20for.20near.20links/near/1972281',
nodes: [TextNode('#api design > notation for near links @ 💬')]));

static const orderedListCustomStart = ContentExample(
'ordered list with custom start',
'5. fifth\n6. sixth',
'<ol start="5">\n<li>fifth</li>\n<li>sixth</li>\n</ol>',
[OrderedListNode(start: 5, [
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('fifth')])],
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('sixth')])],
])],
);

static const orderedListLargeStart = ContentExample(
'ordered list with large start number',
'9999. first\n10000. second',
'<ol start="9999">\n<li>first</li>\n<li>second</li>\n</ol>',
[OrderedListNode(start: 9999, [
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('first')])],
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('second')])],
])],
);

static const spoilerDefaultHeader = ContentExample(
'spoiler with default header',
'```spoiler\nhello world\n```',
Expand Down Expand Up @@ -306,8 +326,8 @@ class ContentExample {
'<p><em>italic</em> <a href="https://zulip.com/">zulip</a></p>\n'
'</div></div>',
[SpoilerNode(
header: [ListNode(ListStyle.ordered, [
[ListNode(ListStyle.unordered, [
header: [OrderedListNode(start: 1, [
[UnorderedListNode([
[HeadingNode(level: HeadingLevel.h2, links: null, nodes: [
TextNode('hello'),
])]
Expand Down Expand Up @@ -836,7 +856,7 @@ class ContentExample {
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div></li>\n</ul>', [
ListNode(ListStyle.unordered, [[
UnorderedListNode([[
ImageNodeList([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
thumbnailUrl: null, loading: false,
Expand All @@ -858,7 +878,7 @@ class ContentExample {
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2"></a></div></li>\n</ul>', [
ListNode(ListStyle.unordered, [[
UnorderedListNode([[
ParagraphNode(wasImplicit: true, links: null, nodes: [
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
TextNode(' '),
Expand Down Expand Up @@ -887,7 +907,7 @@ class ContentExample {
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
'more text</li>\n</ul>', [
ListNode(ListStyle.unordered, [[
UnorderedListNode([[
const ParagraphNode(wasImplicit: true, links: null, nodes: [
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
TextNode(' '),
Expand Down Expand Up @@ -1559,7 +1579,7 @@ void main() {
testParse('<ol>',
// "1. first\n2. then"
'<ol>\n<li>first</li>\n<li>then</li>\n</ol>', const [
ListNode(ListStyle.ordered, [
OrderedListNode(start: 1, [
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('first')])],
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('then')])],
]),
Expand All @@ -1568,7 +1588,7 @@ void main() {
testParse('<ul>',
// "* something\n* another"
'<ul>\n<li>something</li>\n<li>another</li>\n</ul>', const [
ListNode(ListStyle.unordered, [
UnorderedListNode([
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('something')])],
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('another')])],
]),
Expand All @@ -1577,7 +1597,7 @@ void main() {
testParse('implicit paragraph with internal <br>',
// "* a\n b"
'<ul>\n<li>a<br>\n b</li>\n</ul>', const [
ListNode(ListStyle.unordered, [
UnorderedListNode([
[ParagraphNode(wasImplicit: true, links: null, nodes: [
TextNode('a'),
LineBreakInlineNode(),
Expand All @@ -1589,13 +1609,16 @@ void main() {
testParse('explicit paragraphs',
// "* a\n\n b"
'<ul>\n<li>\n<p>a</p>\n<p>b</p>\n</li>\n</ul>', const [
ListNode(ListStyle.unordered, [
UnorderedListNode([
[
ParagraphNode(links: null, nodes: [TextNode('a')]),
ParagraphNode(links: null, nodes: [TextNode('b')]),
],
]),
]);

testParseExample(ContentExample.orderedListCustomStart);
testParseExample(ContentExample.orderedListLargeStart);
});

testParseExample(ContentExample.spoilerDefaultHeader);
Expand Down Expand Up @@ -1628,7 +1651,7 @@ void main() {
testParse('link in list item',
// "* [t](/u)"
'<ul>\n<li><a href="/u">t</a></li>\n</ul>', const [
ListNode(ListStyle.unordered, [
UnorderedListNode([
[ParagraphNode(links: null, wasImplicit: true, nodes: [
LinkNode(url: '/u', nodes: [TextNode('t')]),
])],
Expand Down Expand Up @@ -1695,10 +1718,10 @@ void main() {
'<ol>\n<li>\n<blockquote>\n<h6>two</h6>\n<ul>\n<li>three</li>\n'
'</ul>\n</blockquote>\n<div class="codehilite"><pre><span></span>'
'<code>four\n</code></pre></div>\n\n</li>\n</ol>', const [
ListNode(ListStyle.ordered, [[
OrderedListNode(start: 1, [[
QuotationNode([
HeadingNode(level: HeadingLevel.h6, links: null, nodes: [TextNode('two')]),
ListNode(ListStyle.unordered, [[
UnorderedListNode([[
ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('three')]),
]]),
]),
Expand Down
39 changes: 39 additions & 0 deletions test/widgets/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,45 @@ void main() {
});
});

group('ListNodeWidget', () {
testWidgets('ordered list with custom start', (tester) async {
await prepareContent(tester, plainContent('<ol start="3">\n<li>third</li>\n<li>fourth</li>\n</ol>'));
expect(find.text('3. '), findsOneWidget);
expect(find.text('4. '), findsOneWidget);
expect(find.text('third'), findsOneWidget);
expect(find.text('fourth'), findsOneWidget);
});

testWidgets('list uses correct text baseline alignment', (tester) async {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I guess this is good to check too but it's not the thing I was hoping for in #1329 (comment) — it doesn't check that #1356 was (and remains) fixed.

In particular if I make this edit:

--- lib/widgets/content.dart
+++ lib/widgets/content.dart
@@ -507,7 +507,7 @@ class ListNodeWidget extends StatelessWidget {
         defaultVerticalAlignment: TableCellVerticalAlignment.baseline,
         textBaseline: localizedTextBaseline(context),
         columnWidths: const <int, TableColumnWidth>{
-          0: IntrinsicColumnWidth(),
+          0: FixedColumnWidth(20),
           1: FlexColumnWidth(),
         },

then that should reintroduce #1356 — it'd make the new code behave pretty much just like the old code before your fix — but there isn't currently a test that would detect that and fail.

Can you find a way to write a test that checks that #1356 is fixed?

await prepareContent(tester, plainContent(ContentExample.orderedListLargeStart.html));
final table = tester.widget<Table>(find.byType(Table));
check(table.defaultVerticalAlignment).equals(TableCellVerticalAlignment.baseline);
check(table.textBaseline).equals(localizedTextBaseline(tester.element(find.byType(Table))));
});

testWidgets('ordered list markers have enough space to render completely', (tester) async {
await prepareContent(tester, plainContent(ContentExample.orderedListLargeStart.html));
final marker = tester.renderObject(find.textContaining('9999.')) as RenderParagraph;
// The marker has the height of just one line of text, not more.
final textHeight = marker.size.height;
final lineHeight = marker.text.style!.height! * marker.text.style!.fontSize!;
check(textHeight).equals(lineHeight);
// The marker's text didn't overflow to more lines
// (and get cut off by a `maxLines: 1`).
check(marker).didExceedMaxLines.isFalse();
});

testWidgets('ordered list markers are end-aligned', (tester) async {
await prepareContent(tester, plainContent(ContentExample.orderedListLargeStart.html));
final marker9999 = tester.getRect(find.textContaining('9999.'));
final marker10000 = tester.getRect(find.textContaining('10000.'));
// The markers are aligned at their right edge...
check(marker9999).right.equals(marker10000.right);
// ... and not because they somehow happen to have the same width.
check(marker9999).width.isLessThan(marker10000.width);
});
});

group('Spoiler', () {
testContentSmoke(ContentExample.spoilerDefaultHeader);
testContentSmoke(ContentExample.spoilerPlainCustomHeader);
Expand Down