diff --git a/assets/Source_Code_Pro/LICENSE.md b/assets/Source_Code_Pro/LICENSE.md new file mode 100644 index 0000000000..70288a864f --- /dev/null +++ b/assets/Source_Code_Pro/LICENSE.md @@ -0,0 +1,93 @@ +© 2023 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/Source_Code_Pro/SourceCodeVF-Italic.otf b/assets/Source_Code_Pro/SourceCodeVF-Italic.otf new file mode 100644 index 0000000000..565f6b613b Binary files /dev/null and b/assets/Source_Code_Pro/SourceCodeVF-Italic.otf differ diff --git a/assets/Source_Code_Pro/SourceCodeVF-Upright.otf b/assets/Source_Code_Pro/SourceCodeVF-Upright.otf new file mode 100644 index 0000000000..68798531fe Binary files /dev/null and b/assets/Source_Code_Pro/SourceCodeVF-Upright.otf differ diff --git a/lib/licenses.dart b/lib/licenses.dart new file mode 100644 index 0000000000..1a5933f5d1 --- /dev/null +++ b/lib/licenses.dart @@ -0,0 +1,16 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Our [LicenseEntryCollector] for licenses that aren't included by default. +/// +/// Licenses that ship with our Dart-package dependencies are included +/// automatically. This collects other licenses, such as for fonts we include in +/// our asset bundle. +// If the license text is meant to be read from a file in the asset bundle, +// remember to include the file in the asset bundle by listing its path +// under `assets` in pubspec.yaml. +Stream additionalLicenses() async* { + yield LicenseEntryWithLineBreaks( + ['Source Code Pro'], + await rootBundle.loadString('assets/Source_Code_Pro/LICENSE.md')); +} diff --git a/lib/main.dart b/lib/main.dart index 74acbd678f..ba2f984f25 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,7 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'licenses.dart'; import 'log.dart'; import 'widgets/app.dart'; @@ -8,5 +10,6 @@ void main() { debugLogEnabled = true; return true; }()); + LicenseRegistry.addLicense(additionalLicenses); runApp(const ZulipApp()); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 5bd24b728f..51330866a8 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -7,6 +7,7 @@ import '../model/content.dart'; import '../model/store.dart'; import 'store.dart'; import 'lightbox.dart'; +import 'text.dart'; /// The font size for message content in a plain unstyled paragraph. const double kBaseFontSize = 14; @@ -251,7 +252,7 @@ class CodeBlock extends StatelessWidget { color: const HSLColor.fromAHSL(0.15, 0, 0, 0).toColor())), child: SingleChildScrollViewWithScrollbar( scrollDirection: Axis.horizontal, - child: Text(text, style: _kCodeStyle))); + child: Text(text, style: _kCodeBlockStyle))); } } @@ -351,13 +352,7 @@ InlineSpan inlineCode(InlineCodeNode node) { // TODO `code`: find equivalent of web's `unicode-bidi: embed; direction: ltr` // Use a light gray background, instead of a border. - return TextSpan( - style: const TextStyle( - backgroundColor: Color(0xffeeeeee), - fontSize: 0.825 * kBaseFontSize, - fontFamily: "Source Code Pro", // TODO supply font - fontFamilyFallback: ["monospace"], - ), + return TextSpan(style: _kInlineCodeStyle, children: _buildInlineList(node.nodes)); // Another fun solution -- we can in fact have a border! Like so: @@ -377,12 +372,25 @@ InlineSpan inlineCode(InlineCodeNode node) { // ]); } -const _kCodeStyle = TextStyle( - backgroundColor: Color.fromRGBO(255, 255, 255, 1), - fontSize: 0.825 * kBaseFontSize, - fontFamily: "Source Code Pro", // TODO supply font - fontFamilyFallback: ["monospace"], -); +final _kInlineCodeStyle = kMonospaceTextStyle + .merge(const TextStyle( + backgroundColor: Color(0xffeeeeee), + fontSize: 0.825 * kBaseFontSize)) + .merge( + // TODO(a11y) pass a BuildContext, to handle platform request for bold text. + // To get one, the result of this whole computation (to the TextStyle + // we get at the end) could live on one [InheritedWidget], at the + // MessageList or higher, so the computation doesn't get repeated + // frequently. Then consumers can just look it up on the InheritedWidget. + weightVariableTextStyle(null)); + +final _kCodeBlockStyle = kMonospaceTextStyle + .merge(const TextStyle( + backgroundColor: Color.fromRGBO(255, 255, 255, 1), + fontSize: 0.825 * kBaseFontSize)) + .merge( + // TODO(a11y) pass a BuildContext; see comment in _kInlineCodeStyle above. + weightVariableTextStyle(null)); // const _kInlineCodeLeftBracket = '⸤'; // const _kInlineCodeRightBracket = '⸣'; @@ -639,4 +647,6 @@ InlineSpan _errorUnimplemented(UnimplementedNode node) { const errorStyle = TextStyle(fontWeight: FontWeight.bold, color: Colors.red); -const errorCodeStyle = TextStyle(color: Colors.red, fontFamily: 'monospace'); +final errorCodeStyle = kMonospaceTextStyle + .merge(const TextStyle(color: Colors.red)) + .merge(weightVariableTextStyle(null)); // TODO(a11y) pass a BuildContext diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart new file mode 100644 index 0000000000..2d3152ae92 --- /dev/null +++ b/lib/widgets/text.dart @@ -0,0 +1,102 @@ +import 'dart:io'; +import 'dart:ui'; +import 'package:flutter/widgets.dart'; + +/// A mergeable [TextStyle] with 'Source Code Pro' and platform-aware fallbacks. +/// +/// Callers should also call [weightVariableTextStyle] and merge that in too, +/// because for this font, we use "variable font" assets with a "wght" axis. +/// +/// Example: +/// +/// ```dart +/// kMonospaceTextStyle +/// .merge(const TextStyle(color: Colors.red)) +/// .merge(weightVariableTextStyle(context)) +/// ``` +final TextStyle kMonospaceTextStyle = TextStyle( + fontFamily: 'Source Code Pro', + + // Oddly, iOS doesn't handle 'monospace': + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20monospace.20font.20fallback/near/1570622 + fontFamilyFallback: Platform.isIOS ? ['Menlo', 'Courier'] : ['monospace'], + + inherit: true, +); + +/// A mergeable [TextStyle] to use when the preferred font has a "wght" axis. +/// +/// Some variable fonts can be controlled on a "wght" axis. +/// Use this to set a value on that axis. It uses [TextStyle.fontVariations], +/// along with a [TextStyle.fontWeight] that approximates the given "wght" +/// for the sake of glyphs that need to be rendered by a fallback font +/// (which might not offer a "wght" axis). +/// +/// Use this even to specify normal-weight text, by omitting `wght`; then, +/// [FontWeight.normal.value] will be used. No other layer applies a default, +/// so if you don't use this, you may e.g. get the font's lightest weight. +/// +/// Pass [context] to respect a platform request to draw bold text for +/// accessibility (see [MediaQueryData.boldText]). This handles that request by +/// using [wghtIfPlatformRequestsBold] or if that's null, [FontWeight.bold.value]. +/// +/// Example: +/// +/// ```dart +/// someTextStyle.merge(weightVariableTextStyle(context, wght: 250) +/// ``` +/// +/// See also [FontVariation] for more background on variable fonts. +// TODO(a11y) make `context` required when callers can adapt? +TextStyle weightVariableTextStyle(BuildContext? context, { + double? wght, + double? wghtIfPlatformRequestsBold, +}) { + assert((wght != null) == (wghtIfPlatformRequestsBold != null)); + double value = wght ?? FontWeight.normal.value.toDouble(); + if (context != null && MediaQuery.of(context).boldText) { + // The framework has a condition on [MediaQueryData.boldText] + // in the [Text] widget, but that only affects `fontWeight`. + // [Text] doesn't know where to land on the chosen font's "wght" axis if any, + // and indeed it doesn't seem updated to be aware of variable fonts at all. + value = wghtIfPlatformRequestsBold ?? FontWeight.bold.value.toDouble(); + } + assert(value >= 1 && value <= 1000); // https://fonts.google.com/variablefonts#axis-definitions + + return TextStyle( + fontVariations: [FontVariation('wght', value)], + + // This use of `fontWeight` shouldn't affect glyphs in the preferred, + // "wght"-axis font. If it does, see for debugging: + // https://github.com/zulip/zulip-flutter/issues/65#issuecomment-1550666764 + fontWeight: clampVariableFontWeight(value), + + inherit: true); +} + +/// Find the nearest [FontWeight] constant for a variable-font "wght"-axis value. +/// +/// Use this for a reasonable [TextStyle.fontWeight] for glyphs that need to be +/// rendered by a fallback font that doesn't have a "wght" axis. +/// +/// See also [FontVariation] for background on variable fonts. +FontWeight clampVariableFontWeight(double wght) { + if (wght < 450) { + if (wght < 250) { + if (wght < 150) return FontWeight.w100; // ignore_for_file: curly_braces_in_flow_control_structures + else return FontWeight.w200; + } else { + if (wght < 350) return FontWeight.w300; + else return FontWeight.w400; + } + } else { + if (wght < 650) { + if (wght < 550) return FontWeight.w500; + else return FontWeight.w600; + } else { + if (wght < 750) return FontWeight.w700; + else if (wght < 850) return FontWeight.w800; + else return FontWeight.w900; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 07225b8d73..86325e1a73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,33 +85,13 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/Source_Code_Pro/LICENSE.md - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages + # If adding a font, remember to account for its license in lib/licenses.dart. + fonts: + - family: Source Code Pro + fonts: + - asset: assets/Source_Code_Pro/SourceCodeVF-Upright.otf + - asset: assets/Source_Code_Pro/SourceCodeVF-Italic.otf + style: italic diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart new file mode 100644 index 0000000000..46cbe75b73 --- /dev/null +++ b/test/flutter_checks.dart @@ -0,0 +1,13 @@ +/// `package:checks`-related extensions for the Flutter framework. +import 'dart:ui'; + +import 'package:checks/checks.dart'; +import 'package:flutter/painting.dart'; + +extension TextStyleChecks on Subject { + Subject get inherit => has((t) => t.inherit, 'inherit'); + Subject?> get fontVariations => has((t) => t.fontVariations, 'fontVariations'); + Subject get fontWeight => has((t) => t.fontWeight, 'fontWeight'); + + // TODO others +} diff --git a/test/widgets/text_test.dart b/test/widgets/text_test.dart new file mode 100644 index 0000000000..37e7bbb788 --- /dev/null +++ b/test/widgets/text_test.dart @@ -0,0 +1,117 @@ +import 'dart:ui'; + +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/widgets/text.dart'; + +import '../flutter_checks.dart'; + +void main() { + group('weightVariableTextStyle', () { + Future testWeights( + String description, { + required TextStyle Function(BuildContext context) styleBuilder, + bool platformRequestsBold = false, + required List expectedFontVariations, + required FontWeight expectedFontWeight, + }) async { + testWidgets(description, (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(boldText: platformRequestsBold), + child: Builder(builder: (context) => Text('', style: styleBuilder(context)))))); + + final TextStyle? style = tester.widget(find.byType(Text)).style; + check(style) + .isNotNull() + ..inherit.isTrue() + ..fontVariations.isNotNull().deepEquals(expectedFontVariations) + ..fontWeight.isNotNull().equals(expectedFontWeight); + }); + } + + testWeights('no context passed; default wght values', + styleBuilder: (context) => weightVariableTextStyle(null), + expectedFontVariations: const [FontVariation('wght', 400)], + expectedFontWeight: FontWeight.normal); + testWeights('no context passed; specific wght', + styleBuilder: (context) => weightVariableTextStyle(null, wght: 225, wghtIfPlatformRequestsBold: 425), + expectedFontVariations: const [FontVariation('wght', 225)], + expectedFontWeight: FontWeight.w200); + + testWeights('default values; platform does not request bold', + styleBuilder: (context) => weightVariableTextStyle(context), + platformRequestsBold: false, + expectedFontVariations: const [FontVariation('wght', 400)], + expectedFontWeight: FontWeight.normal); + testWeights('default values; platform requests bold', + styleBuilder: (context) => weightVariableTextStyle(context), + platformRequestsBold: true, + expectedFontVariations: const [FontVariation('wght', 700)], + expectedFontWeight: FontWeight.bold); + testWeights('specific values; platform does not request bold', + styleBuilder: (context) => weightVariableTextStyle(context, wght: 475, wghtIfPlatformRequestsBold: 675), + platformRequestsBold: false, + expectedFontVariations: const [FontVariation('wght', 475)], + expectedFontWeight: FontWeight.w500); + testWeights('specific values; platform requests bold', + platformRequestsBold: true, + styleBuilder: (context) => weightVariableTextStyle(context, wght: 475, wghtIfPlatformRequestsBold: 675), + expectedFontVariations: const [FontVariation('wght', 675)], + expectedFontWeight: FontWeight.w700); + }); + + test('clampVariableFontWeight: FontWeight has the assumed list of values', () { + // Implementation assumes specific FontWeight values; we should + // adapt if these change in a new Flutter version. + check(FontWeight.values).deepEquals([ + FontWeight.w100, FontWeight.w200, FontWeight.w300, + FontWeight.w400, FontWeight.w500, FontWeight.w600, + FontWeight.w700, FontWeight.w800, FontWeight.w900, + ]); + }); + + test('clampVariableFontWeight', () { + check(clampVariableFontWeight(1)) .equals(FontWeight.w100); + check(clampVariableFontWeight(99)) .equals(FontWeight.w100); + check(clampVariableFontWeight(100)) .equals(FontWeight.w100); + check(clampVariableFontWeight(101)) .equals(FontWeight.w100); + + check(clampVariableFontWeight(199)) .equals(FontWeight.w200); + check(clampVariableFontWeight(200)) .equals(FontWeight.w200); + check(clampVariableFontWeight(201)) .equals(FontWeight.w200); + + check(clampVariableFontWeight(250)) .equals(FontWeight.w300); + check(clampVariableFontWeight(299)) .equals(FontWeight.w300); + check(clampVariableFontWeight(300)) .equals(FontWeight.w300); + check(clampVariableFontWeight(301)) .equals(FontWeight.w300); + + check(clampVariableFontWeight(399)) .equals(FontWeight.w400); + check(clampVariableFontWeight(400)) .equals(FontWeight.w400); + check(clampVariableFontWeight(401)) .equals(FontWeight.w400); + + check(clampVariableFontWeight(499)) .equals(FontWeight.w500); + check(clampVariableFontWeight(500)) .equals(FontWeight.w500); + check(clampVariableFontWeight(501)) .equals(FontWeight.w500); + + check(clampVariableFontWeight(599)) .equals(FontWeight.w600); + check(clampVariableFontWeight(600)) .equals(FontWeight.w600); + check(clampVariableFontWeight(601)) .equals(FontWeight.w600); + + check(clampVariableFontWeight(699)) .equals(FontWeight.w700); + check(clampVariableFontWeight(700)) .equals(FontWeight.w700); + check(clampVariableFontWeight(701)) .equals(FontWeight.w700); + + check(clampVariableFontWeight(799)) .equals(FontWeight.w800); + check(clampVariableFontWeight(800)) .equals(FontWeight.w800); + check(clampVariableFontWeight(801)) .equals(FontWeight.w800); + + check(clampVariableFontWeight(899)) .equals(FontWeight.w900); + check(clampVariableFontWeight(900)) .equals(FontWeight.w900); + check(clampVariableFontWeight(901)) .equals(FontWeight.w900); + check(clampVariableFontWeight(999)) .equals(FontWeight.w900); + check(clampVariableFontWeight(1000)) .equals(FontWeight.w900); + }); +}