feat: color a site from its address, and offer a failed web page to the system browser
This commit is contained in:
parent
56db479df0
commit
9d9f09ebc7
24 changed files with 597 additions and 25 deletions
90
app/lib/presentation/reader/oklch.dart
Normal file
90
app/lib/presentation/reader/oklch.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// OkLCH color math. Lightness and chroma are perceptual here, so a palette
|
||||
/// can be moved to another hue without the per-hue lightness corrections that
|
||||
/// rotating an HSL hue would need.
|
||||
|
||||
/// Builds a color from lightness (0..1), chroma and hue (degrees). Chroma is
|
||||
/// reduced until the color fits in sRGB, so a hue that can't hold the asked
|
||||
/// chroma comes back muted rather than clipped to a wrong hue.
|
||||
Color oklch(double lightness, double chroma, double hue) {
|
||||
var low = 0.0;
|
||||
var high = chroma;
|
||||
if (_fitsSrgb(_linearSrgb(lightness, chroma, hue))) {
|
||||
return _color(_linearSrgb(lightness, chroma, hue));
|
||||
}
|
||||
for (var i = 0; i < 16; i++) {
|
||||
final mid = (low + high) / 2;
|
||||
if (_fitsSrgb(_linearSrgb(lightness, mid, hue))) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
return _color(_linearSrgb(lightness, low, hue));
|
||||
}
|
||||
|
||||
/// The WCAG contrast ratio, from 1 (identical) to 21 (black on white).
|
||||
double contrastRatio(Color a, Color b) {
|
||||
final la = _relativeLuminance(a);
|
||||
final lb = _relativeLuminance(b);
|
||||
final lighter = math.max(la, lb);
|
||||
final darker = math.min(la, lb);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/// How far a color sits from gray, in the same units [oklch] takes: the
|
||||
/// bigger it is, the more vivid the color looks.
|
||||
double chromaOf(Color color) {
|
||||
double linear(double value) =>
|
||||
value <= 0.04045 ? value / 12.92 : math.pow((value + 0.055) / 1.055, 2.4) as double;
|
||||
final r = linear(color.r);
|
||||
final g = linear(color.g);
|
||||
final b = linear(color.b);
|
||||
final l = math.pow(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b, 1 / 3) as double;
|
||||
final m = math.pow(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b, 1 / 3) as double;
|
||||
final s = math.pow(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b, 1 / 3) as double;
|
||||
final a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
|
||||
final bb = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
|
||||
return math.sqrt(a * a + bb * bb);
|
||||
}
|
||||
|
||||
List<double> _linearSrgb(double lightness, double chroma, double hue) {
|
||||
final radians = hue * math.pi / 180;
|
||||
final a = chroma * math.cos(radians);
|
||||
final b = chroma * math.sin(radians);
|
||||
final l = math.pow(lightness + 0.3963377774 * a + 0.2158037573 * b, 3) as double;
|
||||
final m = math.pow(lightness - 0.1055613458 * a - 0.0638541728 * b, 3) as double;
|
||||
final s = math.pow(lightness - 0.0894841775 * a - 1.2914855480 * b, 3) as double;
|
||||
return [
|
||||
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||
];
|
||||
}
|
||||
|
||||
bool _fitsSrgb(List<double> rgb) =>
|
||||
rgb.every((channel) => channel >= -0.0001 && channel <= 1.0001);
|
||||
|
||||
Color _color(List<double> rgb) => Color.fromARGB(
|
||||
255,
|
||||
_encode(rgb[0]),
|
||||
_encode(rgb[1]),
|
||||
_encode(rgb[2]),
|
||||
);
|
||||
|
||||
int _encode(double channel) {
|
||||
final clamped = channel.clamp(0.0, 1.0);
|
||||
final encoded = clamped <= 0.0031308
|
||||
? 12.92 * clamped
|
||||
: 1.055 * math.pow(clamped, 1 / 2.4) - 0.055;
|
||||
return (encoded * 255).round().clamp(0, 255);
|
||||
}
|
||||
|
||||
double _relativeLuminance(Color color) {
|
||||
double channel(double value) =>
|
||||
value <= 0.04045 ? value / 12.92 : math.pow((value + 0.055) / 1.055, 2.4) as double;
|
||||
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../mews/document.dart';
|
||||
import 'site_palette.dart';
|
||||
|
||||
/// The client's own presentation, mirroring the palettes of the default
|
||||
/// stylesheet mews-0.1.css (spec 5.1). Mews clients ignore author CSS and use
|
||||
|
|
@ -23,11 +24,16 @@ class MewsPalette {
|
|||
final Color preBackground;
|
||||
}
|
||||
|
||||
/// Visited links keep one color across every palette, generated ones
|
||||
/// included, so the mark of having been somewhere reads the same everywhere.
|
||||
const visitedLight = Color(0xff5b2a86);
|
||||
const visitedDark = Color(0xffc9a3f2);
|
||||
|
||||
const _plainLight = MewsPalette(
|
||||
background: Color(0xfffaf8f3),
|
||||
foreground: Color(0xff1f1f1f),
|
||||
link: Color(0xff1a4f9c),
|
||||
visited: Color(0xff5b2a86),
|
||||
visited: visitedLight,
|
||||
border: Color(0xffc9c4b8),
|
||||
preBackground: Color(0xffefece4),
|
||||
);
|
||||
|
|
@ -36,7 +42,7 @@ const _plainDark = MewsPalette(
|
|||
background: Color(0xff18191b),
|
||||
foreground: Color(0xffe3e1dc),
|
||||
link: Color(0xff8fb8f5),
|
||||
visited: Color(0xffc9a3f2),
|
||||
visited: visitedDark,
|
||||
border: Color(0xff45464a),
|
||||
preBackground: Color(0xff222326),
|
||||
);
|
||||
|
|
@ -45,7 +51,7 @@ const _warmLight = MewsPalette(
|
|||
background: Color(0xfffbf4e8),
|
||||
foreground: Color(0xff2a2420),
|
||||
link: Color(0xff8a3d14),
|
||||
visited: Color(0xff5b2a86),
|
||||
visited: visitedLight,
|
||||
border: Color(0xffc9c4b8),
|
||||
preBackground: Color(0xffefece4),
|
||||
);
|
||||
|
|
@ -54,7 +60,7 @@ const _warmDark = MewsPalette(
|
|||
background: Color(0xff1d1916),
|
||||
foreground: Color(0xffe8dfd3),
|
||||
link: Color(0xfff0b48a),
|
||||
visited: Color(0xffc9a3f2),
|
||||
visited: visitedDark,
|
||||
border: Color(0xff45464a),
|
||||
preBackground: Color(0xff222326),
|
||||
);
|
||||
|
|
@ -63,7 +69,7 @@ const _coolLight = MewsPalette(
|
|||
background: Color(0xfff3f6fa),
|
||||
foreground: Color(0xff1c2430),
|
||||
link: Color(0xff1a4f9c),
|
||||
visited: Color(0xff5b2a86),
|
||||
visited: visitedLight,
|
||||
border: Color(0xffc9c4b8),
|
||||
preBackground: Color(0xffefece4),
|
||||
);
|
||||
|
|
@ -72,7 +78,7 @@ const _coolDark = MewsPalette(
|
|||
background: Color(0xff161a20),
|
||||
foreground: Color(0xffdde4ee),
|
||||
link: Color(0xff8fb8f5),
|
||||
visited: Color(0xffc9a3f2),
|
||||
visited: visitedDark,
|
||||
border: Color(0xff45464a),
|
||||
preBackground: Color(0xff222326),
|
||||
);
|
||||
|
|
@ -81,7 +87,7 @@ const _greenLight = MewsPalette(
|
|||
background: Color(0xfff2f6ef),
|
||||
foreground: Color(0xff1f2a1f),
|
||||
link: Color(0xff24502a),
|
||||
visited: Color(0xff5b2a86),
|
||||
visited: visitedLight,
|
||||
border: Color(0xffc9c4b8),
|
||||
preBackground: Color(0xffefece4),
|
||||
);
|
||||
|
|
@ -90,7 +96,7 @@ const _greenDark = MewsPalette(
|
|||
background: Color(0xff161b16),
|
||||
foreground: Color(0xffdbe6d8),
|
||||
link: Color(0xff9fd39a),
|
||||
visited: Color(0xffc9a3f2),
|
||||
visited: visitedDark,
|
||||
border: Color(0xff45464a),
|
||||
preBackground: Color(0xff222326),
|
||||
);
|
||||
|
|
@ -99,7 +105,7 @@ const _monoLight = MewsPalette(
|
|||
background: Color(0xffe8ebe1),
|
||||
foreground: Color(0xff1e2219),
|
||||
link: Color(0xff1e2219),
|
||||
visited: Color(0xff5b2a86),
|
||||
visited: visitedLight,
|
||||
border: Color(0xffc9c4b8),
|
||||
preBackground: Color(0xffefece4),
|
||||
);
|
||||
|
|
@ -108,14 +114,27 @@ const _monoDark = MewsPalette(
|
|||
background: Color(0xff1b1d18),
|
||||
foreground: Color(0xffc8d0b8),
|
||||
link: Color(0xffc8d0b8),
|
||||
visited: Color(0xffc9a3f2),
|
||||
visited: visitedDark,
|
||||
border: Color(0xff45464a),
|
||||
preBackground: Color(0xff222326),
|
||||
);
|
||||
|
||||
/// Resolves the palette: the reader's variant choice overrides the page's own
|
||||
/// variant unless the choice is "site" (spec 5.3, 8.2).
|
||||
MewsPalette paletteFor({required MewsVariant pageVariant, required String readerVariant, required bool dark}) {
|
||||
/// variant unless the choice is "site" (spec 5.3, 8.2). The choice "host"
|
||||
/// derives the palette from the address instead, and falls back to the plain
|
||||
/// palette for an address with no host.
|
||||
MewsPalette paletteFor({
|
||||
required MewsVariant pageVariant,
|
||||
required String readerVariant,
|
||||
required bool dark,
|
||||
String? url,
|
||||
}) {
|
||||
if (readerVariant == 'host' && url != null) {
|
||||
final derived = sitePalette(url, dark: dark);
|
||||
if (derived != null) {
|
||||
return derived;
|
||||
}
|
||||
}
|
||||
final variant = readerVariant == 'site' ? pageVariant : pageVariantFromName(readerVariant);
|
||||
return switch ((variant, dark)) {
|
||||
(MewsVariant.warm, false) => _warmLight,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import 'dart:io' show Process;
|
||||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, TargetPlatform;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
||||
|
|
@ -114,6 +117,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
pageVariant: page.variant,
|
||||
readerVariant: deps.settings.variant.value,
|
||||
dark: Theme.of(context).brightness == Brightness.dark,
|
||||
url: page.url,
|
||||
).background;
|
||||
|
||||
// The 40em column plus its padding: swipes are accepted
|
||||
|
|
@ -235,6 +239,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
pageVariant: page.variant,
|
||||
readerVariant: variant,
|
||||
dark: Theme.of(context).brightness == Brightness.dark,
|
||||
url: page.url,
|
||||
);
|
||||
return MewsPageView(
|
||||
key: ValueKey(page.url),
|
||||
|
|
@ -263,6 +268,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
pageVariant: page.variant,
|
||||
readerVariant: settings.variant.value,
|
||||
dark: Theme.of(context).brightness == Brightness.dark,
|
||||
url: page.url,
|
||||
);
|
||||
return ColoredBox(
|
||||
color: palette.background,
|
||||
|
|
@ -389,10 +395,46 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Hands [url] to the system's web browser. The reader does not watch
|
||||
/// what happens to it there, and a system with no browser answers nothing
|
||||
/// rather than failing here.
|
||||
///
|
||||
/// The launcher asks GIO on Linux, which can fail to see the session's
|
||||
/// browser from the environment the app runs in, and a binary built before
|
||||
/// the launcher was added answers not at all; in both cases xdg-open asks
|
||||
/// the session directly.
|
||||
Future<void> _openOutside(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
|
||||
var opened = false;
|
||||
try {
|
||||
opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} on Exception {
|
||||
// No answer; the fallback below asks the session itself.
|
||||
}
|
||||
if (opened) return;
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.linux) {
|
||||
final result = await Process.run('xdg-open', [url]);
|
||||
// A missing xdg-open or a session without a browser answers with an
|
||||
// error; nothing more to do but stay where we are.
|
||||
if (result.exitCode != 0) return;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _errorPage(BuildContext context, String? brokenSource) {
|
||||
final strings = AppScope.of(context).strings;
|
||||
final deps = AppScope.of(context).deps;
|
||||
|
||||
// A web address can be shown by the system's browser, so a page that
|
||||
// will not load here can leave; a protocol page has nowhere else to go,
|
||||
// so only trying again is offered there.
|
||||
final failed = deps.browser.currentAddress;
|
||||
final failedScheme = Uri.tryParse(failed)?.scheme;
|
||||
final canOpenOutside =
|
||||
brokenSource == null && (failedScheme == 'http' || failedScheme == 'https');
|
||||
|
||||
Widget content;
|
||||
if (brokenSource == null) {
|
||||
content = Column(
|
||||
|
|
@ -404,6 +446,13 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
onPressed: () => deps.browser.reload(),
|
||||
child: Text(strings.t('error-try-again')),
|
||||
),
|
||||
if (canOpenOutside) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => _openOutside(failed),
|
||||
child: Text(strings.t('error-open-in-browser')),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
} else if (_showBrokenText) {
|
||||
|
|
|
|||
189
app/lib/presentation/reader/site_palette.dart
Normal file
189
app/lib/presentation/reader/site_palette.dart
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'oklch.dart';
|
||||
import 'palettes.dart';
|
||||
|
||||
/// Derives a palette from the address, so a site is recognizable by its color
|
||||
/// before a word of it is read (spec 5.3 lets clients do this instead of
|
||||
/// honoring body variants).
|
||||
///
|
||||
/// The fixed palettes in palettes.dart are one structure in five hues: their
|
||||
/// lightness and chroma per role are near-identical and only the hue differs.
|
||||
/// So a site's address picks a hue, and the structure supplies the rest. Only
|
||||
/// the hue moves, which keeps every generated palette inside the lightness and
|
||||
/// chroma envelope of the fixed ones: no site can come out more saturated than
|
||||
/// Cool. Three background tint strengths and a tinted or neutral foreground
|
||||
/// give 144 looks, none of them louder than the others.
|
||||
|
||||
/// The seed the hue comes from: the host, or `user@host` on a personal page,
|
||||
/// so everyone on a shared host gets their own color. Empty when the address
|
||||
/// has no host, as internal `mews:` pages don't.
|
||||
String siteSeed(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
final host = uri?.host.toLowerCase() ?? '';
|
||||
if (host.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final user = _userPattern.firstMatch(uri!.path)?.group(2);
|
||||
return user == null ? host : '${user.toLowerCase()}@$host';
|
||||
}
|
||||
|
||||
/// Matches the two conventions for a personal page under a shared host.
|
||||
final _userPattern = RegExp(r'^/(~|users/)([^/?]+)', caseSensitive: false);
|
||||
|
||||
/// The palette for an address, or null when it has no host to derive one from.
|
||||
MewsPalette? sitePalette(String url, {required bool dark}) {
|
||||
final seed = siteSeed(url);
|
||||
if (seed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _cache.putIfAbsent('$seed/$dark', () => _generate(seed, dark));
|
||||
}
|
||||
|
||||
/// One palette per site and mode, each built once.
|
||||
final _cache = <String, MewsPalette>{};
|
||||
|
||||
MewsPalette _generate(String seed, bool dark) {
|
||||
final hash = _hash(seed);
|
||||
final hue = (hash % 24) * 15.0;
|
||||
final tint = _tints[(hash >> 5) % _tints.length];
|
||||
final skeleton = dark ? _darkSkeleton : _lightSkeleton;
|
||||
final isForegroundTinted = (hash >> 9) & 1 == 1;
|
||||
|
||||
final background = oklch(
|
||||
skeleton.backgroundLightness + tint.lightnessOffset,
|
||||
skeleton.backgroundChroma * tint.chroma,
|
||||
hue,
|
||||
);
|
||||
return MewsPalette(
|
||||
background: background,
|
||||
foreground: oklch(
|
||||
skeleton.foregroundLightness,
|
||||
isForegroundTinted ? skeleton.foregroundChroma : 0,
|
||||
hue,
|
||||
),
|
||||
link: oklch(
|
||||
_linkLightness(background, skeleton, hue),
|
||||
skeleton.linkChroma,
|
||||
hue,
|
||||
),
|
||||
visited: dark ? visitedDark : visitedLight,
|
||||
border: oklch(
|
||||
skeleton.borderLightness,
|
||||
skeleton.borderChroma * tint.chroma,
|
||||
hue,
|
||||
),
|
||||
preBackground: oklch(
|
||||
skeleton.preLightness + tint.lightnessOffset,
|
||||
skeleton.preChroma * tint.chroma,
|
||||
hue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Walks the link's lightness away from the background until it clears the
|
||||
/// contrast the spec asks of links. A flat hue rotation leaves it between 6.8
|
||||
/// and 7.8 to 1 in light mode, so the greens need this; dark mode clears it
|
||||
/// everywhere and the walk stops at once.
|
||||
double _linkLightness(Color background, _Skeleton skeleton, double hue) {
|
||||
final step = skeleton.isDark ? 0.005 : -0.005;
|
||||
var lightness = skeleton.linkLightness;
|
||||
for (var i = 0; i < 60; i++) {
|
||||
final link = oklch(lightness, skeleton.linkChroma, hue);
|
||||
if (contrastRatio(background, link) >= _minLinkContrast) {
|
||||
break;
|
||||
}
|
||||
lightness += step;
|
||||
}
|
||||
return lightness;
|
||||
}
|
||||
|
||||
/// Above the 7:1 the spec requires of links, leaving room for the rounding to
|
||||
/// 8-bit channels.
|
||||
const _minLinkContrast = 7.5;
|
||||
|
||||
/// How strongly the background carries the hue. The middle step is what the
|
||||
/// fixed palettes use.
|
||||
const _tints = [
|
||||
_Tint(chroma: 0.4, lightnessOffset: -0.02),
|
||||
_Tint(chroma: 1.0, lightnessOffset: 0.0),
|
||||
_Tint(chroma: 2.0, lightnessOffset: 0.015),
|
||||
];
|
||||
|
||||
class _Tint {
|
||||
const _Tint({required this.chroma, required this.lightnessOffset});
|
||||
|
||||
final double chroma;
|
||||
final double lightnessOffset;
|
||||
}
|
||||
|
||||
/// The lightness and chroma the fixed palettes share, measured from them. The
|
||||
/// link chroma sits a hair under the most saturated fixed link so that
|
||||
/// rounding to 8-bit channels can't carry a generated palette past it. A
|
||||
/// dark skeleton offsets its lightness upward instead of downward, so the
|
||||
/// contrast walk knows which way to go.
|
||||
class _Skeleton {
|
||||
const _Skeleton({
|
||||
required this.isDark,
|
||||
required this.backgroundLightness,
|
||||
required this.backgroundChroma,
|
||||
required this.foregroundLightness,
|
||||
required this.foregroundChroma,
|
||||
required this.linkLightness,
|
||||
required this.linkChroma,
|
||||
required this.borderLightness,
|
||||
required this.borderChroma,
|
||||
required this.preLightness,
|
||||
required this.preChroma,
|
||||
});
|
||||
|
||||
final bool isDark;
|
||||
final double backgroundLightness;
|
||||
final double backgroundChroma;
|
||||
final double foregroundLightness;
|
||||
final double foregroundChroma;
|
||||
final double linkLightness;
|
||||
final double linkChroma;
|
||||
final double borderLightness;
|
||||
final double borderChroma;
|
||||
final double preLightness;
|
||||
final double preChroma;
|
||||
}
|
||||
|
||||
const _lightSkeleton = _Skeleton(
|
||||
isDark: false,
|
||||
backgroundLightness: 0.972,
|
||||
backgroundChroma: 0.010,
|
||||
foregroundLightness: 0.258,
|
||||
foregroundChroma: 0.022,
|
||||
linkLightness: 0.438,
|
||||
linkChroma: 0.135,
|
||||
borderLightness: 0.821,
|
||||
borderChroma: 0.017,
|
||||
preLightness: 0.943,
|
||||
preChroma: 0.011,
|
||||
);
|
||||
|
||||
const _darkSkeleton = _Skeleton(
|
||||
isDark: true,
|
||||
backgroundLightness: 0.215,
|
||||
backgroundChroma: 0.011,
|
||||
foregroundLightness: 0.913,
|
||||
foregroundChroma: 0.018,
|
||||
linkLightness: 0.780,
|
||||
linkChroma: 0.096,
|
||||
borderLightness: 0.395,
|
||||
borderChroma: 0.007,
|
||||
preLightness: 0.256,
|
||||
preChroma: 0.006,
|
||||
);
|
||||
|
||||
/// FNV-1a, 32-bit. Any stable hash would do; this one is four lines and gives
|
||||
/// the same palette on every platform and release.
|
||||
int _hash(String seed) {
|
||||
var hash = 0x811c9dc5;
|
||||
for (final byte in seed.codeUnits) {
|
||||
hash = ((hash ^ byte) * 0x01000193) & 0xffffffff;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue