feat: lay our own pages out as interface rather than as articles
This commit is contained in:
parent
e7a8e91334
commit
dfba1cfb16
8 changed files with 162 additions and 55 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue