feat: lay our own pages out as interface rather than as articles

This commit is contained in:
randogoth 2026-10-11 14:03:44 +03:00
parent e7a8e91334
commit dfba1cfb16
8 changed files with 162 additions and 55 deletions

View file

@ -29,15 +29,12 @@ Future<MewsPage> buildBookmarksPage(
field: 'q',
value: query,
),
ParagraphBlock([
if (query.isNotEmpty) ...[
MewsLink(MewsUrls.page(url, {'q': null}),
[MewsText(strings.t('action-clear-search'))]),
const MewsText(actionSeparator),
],
...linkLine([
if (query.isNotEmpty)
action(MewsUrls.page(url, {'q': null}),
strings.t('action-clear-search')),
if (tag != null && tag.isNotEmpty)
MewsLink(MewsUrls.page(url, {'tag': null}),
[MewsText(strings.t('bookmarks-show-all'))]),
action(MewsUrls.page(url, {'tag': null}), strings.t('bookmarks-show-all')),
]),
if (tag != null && tag.isNotEmpty)
ParagraphBlock([

View file

@ -37,15 +37,13 @@ Future<MewsPage> buildHistoryPage(
MewsLink(MewsUrls.history, [MewsText(strings.t('history-clear-no'))]),
]),
] else
ParagraphBlock([
if (query.isNotEmpty) ...[
MewsLink(MewsUrls.page(url, {'q': null}),
[MewsText(strings.t('action-clear-search'))]),
const MewsText(actionSeparator),
],
...linkLine([
if (query.isNotEmpty)
action(MewsUrls.page(url, {'q': null}),
strings.t('action-clear-search')),
if (pages.isNotEmpty)
MewsLink(MewsUrls.page(url, {'confirm': 'clear'}),
[MewsText(strings.t('history-clear'))]),
action(MewsUrls.page(url, {'confirm': 'clear'}),
strings.t('history-clear')),
]),
if (pages.isEmpty)
ParagraphBlock([

View file

@ -9,7 +9,7 @@ import 'mews_urls.dart';
/// Separates independent labels on one line. A separator is layout, so it is a
/// constant here rather than a message.
const actionSeparator = ' ';
const actionSeparator = ' · ';
/// A link with its label: a choice in a group, or an action on a row. For an
/// action [current] is always false.
@ -59,10 +59,9 @@ List<MewsBlock> chrome({
];
}
/// One row in a list: the item, then an optional note on its own line. A date
/// goes inside the link, the way a Mews page writes its own dated links (spec
/// 6.1), so the whole row is one target. One paragraph per row keeps rows
/// close together, since each paragraph carries its own bottom padding.
/// One row in a list: the item as a link, then its note and date on a second,
/// quieter line, then its chips and actions. One paragraph per row keeps the
/// lines together, since each paragraph carries its own bottom padding.
MewsListItem itemRow({
required String href,
required String label,
@ -72,23 +71,28 @@ MewsListItem itemRow({
List<Choice> chips = const [],
List<Choice> actions = const [],
}) {
final text = date == null ? label : '$date $label';
final meta = [?note, ?date];
return MewsListItem([
ParagraphBlock([
MewsLink(href, [
if (strong)
MewsEmphasis(EmphasisStyle.strong, [MewsText(text)])
MewsEmphasis(EmphasisStyle.strong, [MewsText(label)])
else
MewsText(text),
MewsText(label),
]),
if (note != null) ...[
if (meta.isNotEmpty) ...[
const MewsLineBreak(),
MewsEmphasis(EmphasisStyle.emphasis, [MewsText(note)]),
MewsEmphasis(
EmphasisStyle.emphasis, [MewsText(meta.join(actionSeparator))]),
],
if (chips.isNotEmpty) ...[
const MewsLineBreak(),
..._separated(
[for (final chip in chips) MewsLink(chip.href, [MewsText(chip.label)])]),
// A leading hash tells a tag apart from the actions beside it, which
// links alone give no way to distinguish.
..._separated([
for (final chip in chips)
MewsLink(chip.href, [MewsText('#${chip.label}')]),
]),
],
if (actions.isNotEmpty) ...[
const MewsLineBreak(),
@ -175,6 +179,16 @@ FormBlock textForm({
);
}
/// A line of links with the separator between them, never trailing. Returns
/// nothing when there is no link to show, so a page never holds an empty line.
List<MewsBlock> linkLine(List<Choice> links) {
if (links.isEmpty) return const [];
return [
ParagraphBlock(_separated(
[for (final link in links) MewsLink(link.href, [MewsText(link.label)])])),
];
}
/// A plain list of links, used for menus and library lists alike.
ListBlock itemList(List<MewsListItem> items) =>
ListBlock(ordered: false, start: 1, items: items);

View file

@ -30,15 +30,12 @@ Future<MewsPage> buildSubscriptionsPage(
field: 'q',
value: query,
),
ParagraphBlock([
if (query.isNotEmpty) ...[
MewsLink(MewsUrls.page(url, {'q': null}),
[MewsText(strings.t('action-clear-search'))]),
const MewsText(actionSeparator),
],
...linkLine([
if (query.isNotEmpty)
action(MewsUrls.page(url, {'q': null}),
strings.t('action-clear-search')),
if (tag != null && tag.isNotEmpty)
MewsLink(MewsUrls.page(url, {'tag': null}),
[MewsText(strings.t('subscriptions-show-all'))]),
action(MewsUrls.page(url, {'tag': null}), strings.t('subscriptions-show-all')),
]),
if (tag != null && tag.isNotEmpty)
ParagraphBlock([

View file

@ -127,6 +127,7 @@ class _ThemedApp extends StatelessWidget {
_ => ThemeMode.system,
};
final settings = deps.settings;
final font = settings.font.value;
final page = deps.browser.currentPage.value;
final variant = settings.variant.value == 'site'
? (page?.variant ?? MewsVariant.plain)
@ -135,14 +136,18 @@ class _ThemedApp extends StatelessWidget {
return MaterialApp(
title: 'Mews',
themeMode: mode,
theme: _themeFor(paletteFor(pageVariant: variant, readerVariant: 'site', dark: false)),
darkTheme: _themeFor(paletteFor(pageVariant: variant, readerVariant: 'site', dark: true)),
theme: _themeFor(
paletteFor(pageVariant: variant, readerVariant: 'site', dark: false),
font),
darkTheme: _themeFor(
paletteFor(pageVariant: variant, readerVariant: 'site', dark: true),
font),
home: const ReaderScreen(),
);
});
}
ThemeData _themeFor(MewsPalette palette) {
ThemeData _themeFor(MewsPalette palette, String fontFamily) {
final dark = palette.background.computeLuminance() < 0.5;
final scheme = ColorScheme(
brightness: dark ? Brightness.dark : Brightness.light,
@ -163,6 +168,9 @@ class _ThemedApp extends StatelessWidget {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
// The chrome reads in the typeface the reader chose for pages, so the
// address bar and the page below it are not two different faces.
fontFamily: fontFamily,
scaffoldBackgroundColor: palette.background,
// Selection highlights and the caret follow the palette too.
textSelectionTheme: TextSelectionThemeData(

View file

@ -20,6 +20,7 @@ class MewsPageView extends StatefulWidget {
required this.onLinkTap,
required this.onFormSubmit,
this.scrollController,
this.ownPage = false,
super.key,
});
@ -32,6 +33,12 @@ class MewsPageView extends StatefulWidget {
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
final ScrollController? scrollController;
/// Whether this is one of the client's own pages. Those are built from the
/// same blocks, but they are interface rather than an article, so they are
/// laid out denser and wider, rows are divided instead of bulleted, and
/// links read as controls rather than prose.
final bool ownPage;
@override
State<MewsPageView> createState() => _MewsPageViewState();
}
@ -86,8 +93,10 @@ class _MewsPageViewState extends State<MewsPageView> {
children: [
Center(
child: ConstrainedBox(
// max-width: 40em
constraints: BoxConstraints(maxWidth: 40 * base),
// max-width: 40em for an article; our own pages are lists, so
// they get more room before they stop growing.
constraints:
BoxConstraints(maxWidth: (widget.ownPage ? 56 : 40) * base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: blocks,
@ -105,7 +114,11 @@ class _MewsPageViewState extends State<MewsPageView> {
padding: EdgeInsets.only(bottom: base),
child: Text(
text,
style: TextStyle(color: palette.foreground.withValues(alpha: 0.7), fontSize: base * 0.85),
style: TextStyle(
color: palette.foreground.withValues(alpha: 0.7),
fontFamily: widget.fontFamily,
fontSize: base * 0.85,
),
),
);
@ -113,12 +126,19 @@ class _MewsPageViewState extends State<MewsPageView> {
Widget _block(BuildContext context, MewsBlock block, double base) {
final palette = widget.palette;
final foreground = TextStyle(color: palette.foreground, fontSize: base, height: 1.5);
final foreground = TextStyle(
color: palette.foreground,
fontFamily: widget.fontFamily,
fontSize: base,
height: 1.5,
);
return switch (block) {
HeadingBlock(:final level, :final inline) => Padding(
padding: EdgeInsets.only(
top: level == 1 ? base * 0.5 : base * 1.6,
top: level == 1
? base * 0.5
: (widget.ownPage ? base : base * 1.6),
bottom: base * 0.5,
),
child: Text.rich(
@ -127,11 +147,16 @@ class _MewsPageViewState extends State<MewsPageView> {
fontWeight: FontWeight.bold,
height: 1.25,
fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1],
// A section label on our own pages, not a document heading.
color: widget.ownPage && level > 1
? palette.foreground.withValues(alpha: 0.7)
: null,
letterSpacing: widget.ownPage && level > 1 ? 0.4 : null,
),
),
),
ParagraphBlock(:final inline) => Padding(
padding: EdgeInsets.only(bottom: base),
padding: EdgeInsets.only(bottom: widget.ownPage ? base * 0.35 : base),
child: Text.rich(TextSpan(children: _inline(inline)), style: foreground),
),
AddressBlock(:final inline) => Padding(
@ -168,9 +193,33 @@ class _MewsPageViewState extends State<MewsPageView> {
),
),
RuleBlock() => Padding(
padding: EdgeInsets.symmetric(vertical: base * 2),
padding: EdgeInsets.symmetric(
vertical: widget.ownPage ? base * 0.6 : base * 2),
child: Divider(color: palette.border, height: 1),
),
// Rows on our own pages: separated by a line rather than marked with a
// bullet, so a list of bookmarks or settings reads as a list of rows.
ListBlock(:final items) when widget.ownPage => Padding(
padding: EdgeInsets.only(bottom: base * 0.35),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0) Divider(color: palette.border, height: 1),
Padding(
padding: EdgeInsets.only(top: base * 0.45),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in items[i].blocks)
_block(context, child, base),
],
),
),
],
],
),
),
ListBlock(:final ordered, :final start, :final items) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Column(
@ -274,6 +323,7 @@ class _MewsPageViewState extends State<MewsPageView> {
form: block,
foreground: foreground,
onFormSubmit: widget.onFormSubmit,
ownPage: widget.ownPage,
),
),
};
@ -311,6 +361,10 @@ class _MewsPageViewState extends State<MewsPageView> {
MewsEmphasis(:final style, :final children) => TextSpan(
style: switch (style) {
EmphasisStyle.strong => const TextStyle(fontWeight: FontWeight.bold),
EmphasisStyle.emphasis when widget.ownPage => TextStyle(
color: palette.foreground.withValues(alpha: 0.65),
fontSize: 15.0 * widget.fontScale,
),
EmphasisStyle.emphasis => const TextStyle(fontStyle: FontStyle.italic),
EmphasisStyle.term => const TextStyle(fontStyle: FontStyle.italic),
EmphasisStyle.quote => const TextStyle(fontStyle: FontStyle.italic),
@ -320,7 +374,11 @@ class _MewsPageViewState extends State<MewsPageView> {
MewsLink(:final href, :final children) => TextSpan(
style: TextStyle(
color: widget.visitedUrls.contains(href) ? palette.visited : palette.link,
decoration: TextDecoration.underline,
// Underlines mark links in prose; on our own pages they are
// controls, so weight carries them instead.
decoration:
widget.ownPage ? TextDecoration.none : TextDecoration.underline,
fontWeight: widget.ownPage ? FontWeight.w500 : null,
),
recognizer: _recognizer(node),
children: _inline(children, _recognizer(node))),
@ -342,12 +400,17 @@ class _MewsForm extends StatefulWidget {
required this.form,
required this.foreground,
required this.onFormSubmit,
this.ownPage = false,
});
final FormBlock form;
final TextStyle foreground;
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
/// On our own pages a form is a search box or a one-field prompt, so it sits
/// on one line instead of filling the column like a page's own form.
final bool ownPage;
@override
State<_MewsForm> createState() => _MewsFormState();
}
@ -370,6 +433,38 @@ class _MewsFormState extends State<_MewsForm> {
final form = widget.form;
final style = widget.foreground;
Widget button(MewsSubmitField field) => FilledButton(
onPressed: () => field.reset ? _reset(form) : _submit(form),
child: Text(
field.label ??
(field.reset
? strings.t('form-reset-default')
: strings.t('form-submit-default')),
style: TextStyle(fontFamily: style.fontFamily),
),
);
if (widget.ownPage) {
final inputs = form.fields.where((f) => f is! MewsSubmitField).toList();
return Form(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (final field in inputs)
if (field is! MewsHiddenField)
Expanded(child: _field(field, style))
else
_field(field, style),
for (final field in form.fields.whereType<MewsSubmitField>())
Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: button(field),
),
],
),
);
}
return Form(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -378,13 +473,7 @@ class _MewsFormState extends State<_MewsForm> {
for (final field in form.fields.whereType<MewsSubmitField>())
Padding(
padding: const EdgeInsets.only(top: 8),
child: FilledButton(
onPressed: () => field.reset ? _reset(form) : _submit(form),
child: Text(
field.label ??
(field.reset ? strings.t('form-reset-default') : strings.t('form-submit-default')),
),
),
child: button(field),
),
],
),

View file

@ -4,6 +4,7 @@ import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../../application/controllers/browser_controller.dart'
show BrokenPage, LoadFailed;
import '../../application/pages/mews_urls.dart';
import '../../mews/document.dart';
import '../../mews/parser.dart' show mewsPlainText;
import '../app.dart';
@ -159,6 +160,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
return MewsPageView(
key: ValueKey(page.url),
page: page,
ownPage: MewsUrls.isInternal(page.url),
palette: palette,
fontFamily: font,
fontScale: fontScale,
@ -186,6 +188,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
color: palette.background,
child: MewsPageView(
page: page,
ownPage: MewsUrls.isInternal(page.url),
palette: palette,
fontFamily: settings.font.value,
fontScale: settings.fontScale.value,