| // Copyright 2014 The Flutter Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| // Regenerates a Dart file with a class containing IconData constants. | |
| // See https://github.com/flutter/flutter/wiki/Updating-Material-Design-Fonts-&-Icons | |
| // Should be idempotent with: | |
| // dart dev/tools/update_icons.dart --new-codepoints bin/cache/artifacts/material_fonts/codepoints | |
| import 'dart:collection'; | |
| import 'dart:convert' show LineSplitter; | |
| import 'dart:io'; | |
| import 'package:args/args.dart'; | |
| import 'package:meta/meta.dart'; | |
| import 'package:path/path.dart' as path; | |
| const String _iconsPathOption = 'icons'; | |
| const String _iconsTemplatePathOption = 'icons-template'; | |
| const String _newCodepointsPathOption = 'new-codepoints'; | |
| const String _oldCodepointsPathOption = 'old-codepoints'; | |
| const String _fontFamilyOption = 'font-family'; | |
| const String _possibleStyleSuffixesOption = 'style-suffixes'; | |
| const String _classNameOption = 'class-name'; | |
| const String _enforceSafetyChecks = 'enforce-safety-checks'; | |
| const String _dryRunOption = 'dry-run'; | |
| const String _defaultIconsPath = 'packages/flutter/lib/src/material/icons.dart'; | |
| const String _defaultNewCodepointsPath = 'codepoints'; | |
| const String _defaultOldCodepointsPath = 'bin/cache/artifacts/material_fonts/codepoints'; | |
| const String _defaultFontFamily = 'MaterialIcons'; | |
| const List<String> _defaultPossibleStyleSuffixes = <String>[ | |
| '_outlined', | |
| '_rounded', | |
| '_sharp', | |
| ]; | |
| const String _defaultClassName = 'Icons'; | |
| const String _defaultDemoFilePath = '/tmp/new_icons_demo.dart'; | |
| const String _beginGeneratedMark = '// BEGIN GENERATED ICONS'; | |
| const String _endGeneratedMark = '// END GENERATED ICONS'; | |
| const String _beginPlatformAdaptiveGeneratedMark = '// BEGIN GENERATED PLATFORM ADAPTIVE ICONS'; | |
| const String _endPlatformAdaptiveGeneratedMark = '// END GENERATED PLATFORM ADAPTIVE ICONS'; | |
| const Map<String, List<String>> _platformAdaptiveIdentifiers = <String, List<String>>{ | |
| // Mapping of Flutter IDs to an Android/agnostic ID and an iOS ID. | |
| // Flutter IDs can be anything, but should be chosen to be agnostic. | |
| 'arrow_back': <String>['arrow_back', 'arrow_back_ios'], | |
| 'arrow_forward': <String>['arrow_forward', 'arrow_forward_ios'], | |
| 'flip_camera': <String>['flip_camera_android', 'flip_camera_ios'], | |
| 'more': <String>['more_vert', 'more_horiz'], | |
| 'share': <String>['share', 'ios_share'], | |
| }; | |
| // Rewrite certain Flutter IDs (numbers) using prefix matching. | |
| const Map<String, String> _identifierPrefixRewrites = <String, String>{ | |
| '1': 'one_', | |
| '2': 'two_', | |
| '3': 'three_', | |
| '4': 'four_', | |
| '5': 'five_', | |
| '6': 'six_', | |
| '7': 'seven_', | |
| '8': 'eight_', | |
| '9': 'nine_', | |
| '10': 'ten_', | |
| '11': 'eleven_', | |
| '12': 'twelve_', | |
| '13': 'thirteen_', | |
| '14': 'fourteen_', | |
| '15': 'fifteen_', | |
| '16': 'sixteen_', | |
| '17': 'seventeen_', | |
| '18': 'eighteen_', | |
| '19': 'nineteen_', | |
| '20': 'twenty_', | |
| '21': 'twenty_one_', | |
| '22': 'twenty_two_', | |
| '23': 'twenty_three_', | |
| '24': 'twenty_four_', | |
| '30': 'thirty_', | |
| '60': 'sixty_', | |
| '123': 'onetwothree', | |
| '360': 'threesixty', | |
| '2d': 'twod', | |
| '3d': 'threed', | |
| '3d_rotation': 'threed_rotation', | |
| }; | |
| // Rewrite certain Flutter IDs (reserved keywords) using exact matching. | |
| const Map<String, String> _identifierExactRewrites = <String, String>{ | |
| 'class': 'class_', | |
| 'new': 'new_', | |
| 'switch': 'switch_', | |
| 'try': 'try_sms_star', | |
| 'door_back': 'door_back_door', | |
| 'door_front': 'door_front_door', | |
| }; | |
| const Set<String> _iconsMirroredWhenRTL = <String>{ | |
| // This list is obtained from: | |
| // https://developers.google.com/fonts/docs/material_icons#which_icons_should_be_mirrored_for_rtl | |
| 'arrow_back', | |
| 'arrow_back_ios', | |
| 'arrow_forward', | |
| 'arrow_forward_ios', | |
| 'arrow_left', | |
| 'arrow_right', | |
| 'assignment', | |
| 'assignment_return', | |
| 'backspace', | |
| 'battery_unknown', | |
| 'call_made', | |
| 'call_merge', | |
| 'call_missed', | |
| 'call_missed_outgoing', | |
| 'call_received', | |
| 'call_split', | |
| 'chevron_left', | |
| 'chevron_right', | |
| 'chrome_reader_mode', | |
| 'device_unknown', | |
| 'dvr', | |
| 'event_note', | |
| 'featured_play_list', | |
| 'featured_video', | |
| 'first_page', | |
| 'flight_land', | |
| 'flight_takeoff', | |
| 'format_indent_decrease', | |
| 'format_indent_increase', | |
| 'format_list_bulleted', | |
| 'forward', | |
| 'functions', | |
| 'help', | |
| 'help_outline', | |
| 'input', | |
| 'keyboard_backspace', | |
| 'keyboard_tab', | |
| 'label', | |
| 'label_important', | |
| 'label_outline', | |
| 'last_page', | |
| 'launch', | |
| 'list', | |
| 'live_help', | |
| 'mobile_screen_share', | |
| 'multiline_chart', | |
| 'navigate_before', | |
| 'navigate_next', | |
| 'next_week', | |
| 'note', | |
| 'open_in_new', | |
| 'playlist_add', | |
| 'queue_music', | |
| 'redo', | |
| 'reply', | |
| 'reply_all', | |
| 'screen_share', | |
| 'send', | |
| 'short_text', | |
| 'show_chart', | |
| 'sort', | |
| 'star_half', | |
| 'subject', | |
| 'trending_flat', | |
| 'toc', | |
| 'trending_down', | |
| 'trending_up', | |
| 'undo', | |
| 'view_list', | |
| 'view_quilt', | |
| 'wrap_text', | |
| }; | |
| void main(List<String> args) { | |
| // If we're run from the `tools` dir, set the cwd to the repo root. | |
| if (path.basename(Directory.current.path) == 'tools') { | |
| Directory.current = Directory.current.parent.parent; | |
| } | |
| final ArgResults argResults = _handleArguments(args); | |
| final File iconsFile = File(path.normalize(path.absolute(argResults[_iconsPathOption] as String))); | |
| if (!iconsFile.existsSync()) { | |
| stderr.writeln('Error: Icons file not found: ${iconsFile.path}'); | |
| exit(1); | |
| } | |
| final File iconsTemplateFile = File(path.normalize(path.absolute(argResults[_iconsTemplatePathOption] as String))); | |
| if (!iconsTemplateFile.existsSync()) { | |
| stderr.writeln('Error: Icons template file not found: ${iconsTemplateFile.path}'); | |
| exit(1); | |
| } | |
| final File newCodepointsFile = File(argResults[_newCodepointsPathOption] as String); | |
| if (!newCodepointsFile.existsSync()) { | |
| stderr.writeln('Error: New codepoints file not found: ${newCodepointsFile.path}'); | |
| exit(1); | |
| } | |
| final File oldCodepointsFile = File(argResults[_oldCodepointsPathOption] as String); | |
| if (!oldCodepointsFile.existsSync()) { | |
| stderr.writeln('Error: Old codepoints file not found: ${oldCodepointsFile.path}'); | |
| exit(1); | |
| } | |
| final String newCodepointsString = newCodepointsFile.readAsStringSync(); | |
| final Map<String, String> newTokenPairMap = stringToTokenPairMap(newCodepointsString); | |
| final String oldCodepointsString = oldCodepointsFile.readAsStringSync(); | |
| final Map<String, String> oldTokenPairMap = stringToTokenPairMap(oldCodepointsString); | |
| stderr.writeln('Performing safety checks'); | |
| final bool isSuperset = testIsSuperset(newTokenPairMap, oldTokenPairMap); | |
| final bool isStable = testIsStable(newTokenPairMap, oldTokenPairMap); | |
| if ((!isSuperset || !isStable) && argResults[_enforceSafetyChecks] as bool) { | |
| exit(1); | |
| } | |
| final String iconsTemplateContents = iconsTemplateFile.readAsStringSync(); | |
| stderr.writeln("Generating icons ${argResults[_dryRunOption] as bool ? '' : 'to ${iconsFile.path}'}"); | |
| final String newIconsContents = _regenerateIconsFile( | |
| iconsTemplateContents, | |
| newTokenPairMap, | |
| argResults[_fontFamilyOption] as String, | |
| argResults[_classNameOption] as String, | |
| argResults[_enforceSafetyChecks] as bool, | |
| ); | |
| if (argResults[_dryRunOption] as bool) { | |
| stdout.write(newIconsContents); | |
| } else { | |
| iconsFile.writeAsStringSync(newIconsContents); | |
| final SplayTreeMap<String, String> sortedNewTokenPairMap = SplayTreeMap<String, String>.of(newTokenPairMap); | |
| _regenerateCodepointsFile(oldCodepointsFile, sortedNewTokenPairMap); | |
| sortedNewTokenPairMap.removeWhere((String key, String value) => oldTokenPairMap.containsKey(key)); | |
| _generateIconDemo(File(_defaultDemoFilePath), sortedNewTokenPairMap); | |
| } | |
| } | |
| ArgResults _handleArguments(List<String> args) { | |
| final ArgParser argParser = ArgParser() | |
| ..addOption(_iconsPathOption, | |
| defaultsTo: _defaultIconsPath, | |
| help: 'Location of the material icons file') | |
| ..addOption(_iconsTemplatePathOption, | |
| defaultsTo: _defaultIconsPath, | |
| help: | |
| 'Location of the material icons file template. Usually the same as --$_iconsPathOption') | |
| ..addOption(_newCodepointsPathOption, | |
| defaultsTo: _defaultNewCodepointsPath, | |
| help: 'Location of the new codepoints directory') | |
| ..addOption(_oldCodepointsPathOption, | |
| defaultsTo: _defaultOldCodepointsPath, | |
| help: 'Location of the existing codepoints directory') | |
| ..addOption(_fontFamilyOption, | |
| defaultsTo: _defaultFontFamily, | |
| help: 'The font family to use for the IconData constants') | |
| ..addMultiOption(_possibleStyleSuffixesOption, | |
| defaultsTo: _defaultPossibleStyleSuffixes, | |
| help: 'A comma-separated list of suffixes (typically an optional ' | |
| 'family + a style) e.g. _outlined, _monoline_filled') | |
| ..addOption(_classNameOption, | |
| defaultsTo: _defaultClassName, | |
| help: 'The containing class for all icons') | |
| ..addFlag(_enforceSafetyChecks, | |
| defaultsTo: true, | |
| help: 'Whether to exit if safety checks fail (e.g. codepoints are missing or unstable') | |
| ..addFlag(_dryRunOption); | |
| argParser.addFlag('help', abbr: 'h', negatable: false, callback: (bool help) { | |
| if (help) { | |
| print(argParser.usage); | |
| exit(1); | |
| } | |
| }); | |
| return argParser.parse(args); | |
| } | |
| Map<String, String> stringToTokenPairMap(String codepointData) { | |
| final Iterable<String> cleanData = LineSplitter.split(codepointData) | |
| .map((String line) => line.trim()) | |
| .where((String line) => line.isNotEmpty); | |
| final Map<String, String> pairs = <String, String>{}; | |
| for (final String line in cleanData) { | |
| final List<String> tokens = line.split(' '); | |
| if (tokens.length != 2) { | |
| throw FormatException('Unexpected codepoint data: $line'); | |
| } | |
| pairs.putIfAbsent(tokens[0], () => tokens[1]); | |
| } | |
| return pairs; | |
| } | |
| String _regenerateIconsFile( | |
| String templateFileContents, | |
| Map<String, String> tokenPairMap, | |
| String fontFamily, | |
| String className, | |
| bool enforceSafetyChecks, | |
| ) { | |
| final List<Icon> newIcons = tokenPairMap.entries | |
| .map((MapEntry<String, String> entry) => | |
| Icon(entry, fontFamily: fontFamily, className: className)) | |
| .toList(); | |
| newIcons.sort((Icon a, Icon b) => a._compareTo(b)); | |
| final StringBuffer buf = StringBuffer(); | |
| bool generating = false; | |
| for (final String line in LineSplitter.split(templateFileContents)) { | |
| if (!generating) { | |
| buf.writeln(line); | |
| } | |
| // Generate for PlatformAdaptiveIcons | |
| if (line.contains(_beginPlatformAdaptiveGeneratedMark)) { | |
| generating = true; | |
| final List<String> platformAdaptiveDeclarations = <String>[]; | |
| _platformAdaptiveIdentifiers.forEach((String flutterId, List<String> ids) { | |
| // Automatically finds and generates all icon declarations. | |
| for (final String style in <String>['', '_outlined', '_rounded', '_sharp']) { | |
| try { | |
| final Icon agnosticIcon = newIcons.firstWhere( | |
| (Icon icon) => icon.id == '${ids[0]}$style', | |
| orElse: () => throw ids[0]); | |
| final Icon iOSIcon = newIcons.firstWhere( | |
| (Icon icon) => icon.id == '${ids[1]}$style', | |
| orElse: () => throw ids[1]); | |
| platformAdaptiveDeclarations.add( | |
| agnosticIcon.platformAdaptiveDeclaration('$flutterId$style', iOSIcon), | |
| ); | |
| } catch (e) { | |
| if (style == '') { | |
| // Throw an error for baseline icons. | |
| stderr.writeln("❌ Platform adaptive icon '$e' not found."); | |
| if (enforceSafetyChecks) { | |
| stderr.writeln('Safety checks failed'); | |
| exit(1); | |
| } | |
| } else { | |
| // Ignore errors for styled icons since some don't exist. | |
| } | |
| } | |
| } | |
| }); | |
| buf.write(platformAdaptiveDeclarations.join()); | |
| } else if (line.contains(_endPlatformAdaptiveGeneratedMark)) { | |
| generating = false; | |
| buf.writeln(line); | |
| } | |
| // Generate for Icons | |
| if (line.contains(_beginGeneratedMark)) { | |
| generating = true; | |
| final String iconDeclarationsString = newIcons.map((Icon icon) => icon.fullDeclaration).join(); | |
| buf.write(iconDeclarationsString); | |
| } else if (line.contains(_endGeneratedMark)) { | |
| generating = false; | |
| buf.writeln(line); | |
| } | |
| } | |
| return buf.toString(); | |
| } | |
| @visibleForTesting | |
| bool testIsSuperset(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) { | |
| final Set<String> newCodepointsSet = newCodepoints.keys.toSet(); | |
| final Set<String> oldCodepointsSet = oldCodepoints.keys.toSet(); | |
| final int diff = newCodepointsSet.length - oldCodepointsSet.length; | |
| if (diff > 0) { | |
| stderr.writeln('🆕 $diff new codepoints: ${newCodepointsSet.difference(oldCodepointsSet)}'); | |
| } | |
| if (!newCodepointsSet.containsAll(oldCodepointsSet)) { | |
| stderr.writeln( | |
| '❌ new codepoints file does not contain all ${oldCodepointsSet.length} ' | |
| 'existing codepoints. Missing: ${oldCodepointsSet.difference(newCodepointsSet)}'); | |
| return false; | |
| } else { | |
| stderr.writeln('✅ new codepoints file contains all ${oldCodepointsSet.length} existing codepoints'); | |
| } | |
| return true; | |
| } | |
| @visibleForTesting | |
| bool testIsStable(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) { | |
| final int oldCodepointsCount = oldCodepoints.length; | |
| final List<String> unstable = <String>[]; | |
| oldCodepoints.forEach((String key, String value) { | |
| if (newCodepoints.containsKey(key)) { | |
| if (value != newCodepoints[key]) { | |
| unstable.add(key); | |
| } | |
| } | |
| }); | |
| if (unstable.isNotEmpty) { | |
| stderr.writeln('❌ out of $oldCodepointsCount existing codepoints, ${unstable.length} were unstable: $unstable'); | |
| return false; | |
| } else { | |
| stderr.writeln('✅ all existing $oldCodepointsCount codepoints are stable'); | |
| return true; | |
| } | |
| } | |
| void _regenerateCodepointsFile(File oldCodepointsFile, Map<String, String> tokenPairMap) { | |
| stderr.writeln('Regenerating old codepoints file ${oldCodepointsFile.path}'); | |
| final StringBuffer buf = StringBuffer(); | |
| tokenPairMap.forEach((String key, String value) => buf.writeln('$key $value')); | |
| oldCodepointsFile.writeAsStringSync(buf.toString()); | |
| } | |
| void _generateIconDemo(File demoFilePath, Map<String, String> tokenPairMap) { | |
| if (tokenPairMap.isEmpty) { | |
| stderr.writeln('No new icons, skipping generating icon demo'); | |
| return; | |
| } | |
| stderr.writeln('Generating icon demo at $_defaultDemoFilePath'); | |
| final StringBuffer newIconUsages = StringBuffer(); | |
| for (final MapEntry<String, String> entry in tokenPairMap.entries) { | |
| newIconUsages.writeln(Icon(entry).usage); | |
| } | |
| final String demoFileContents = ''' | |
| import 'package:flutter/material.dart'; | |
| void main() => runApp(const IconDemo()); | |
| class IconDemo extends StatelessWidget { | |
| const IconDemo({ Key? key }) : super(key: key); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| home: Scaffold( | |
| body: Wrap( | |
| children: const [ | |
| $newIconUsages | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| '''; | |
| demoFilePath.writeAsStringSync(demoFileContents); | |
| } | |
| class Icon { | |
| // Parse tokenPair (e.g. {"6_ft_apart_outlined": "e004"}). | |
| Icon(MapEntry<String, String> tokenPair, { | |
| this.fontFamily = _defaultFontFamily, | |
| this.possibleStyleSuffixes = _defaultPossibleStyleSuffixes, | |
| this.className = _defaultClassName, | |
| }) { | |
| id = tokenPair.key; | |
| hexCodepoint = tokenPair.value; | |
| // Determine family and HTML class suffix for Dartdoc. | |
| if (id.endsWith('_gm_outlined')) { | |
| dartdocFamily = 'GM'; | |
| dartdocHtmlSuffix = '-outlined'; | |
| } else if (id.endsWith('_gm_filled')) { | |
| dartdocFamily = 'GM'; | |
| dartdocHtmlSuffix = '-filled'; | |
| } else if (id.endsWith('_monoline_outlined')) { | |
| dartdocFamily = 'Monoline'; | |
| dartdocHtmlSuffix = '-outlined'; | |
| } else if (id.endsWith('_monoline_filled')) { | |
| dartdocFamily = 'Monoline'; | |
| dartdocHtmlSuffix = '-filled'; | |
| } else { | |
| dartdocFamily = 'material'; | |
| if (id.endsWith('_baseline')) { | |
| id = _removeLast(id, '_baseline'); | |
| dartdocHtmlSuffix = ''; | |
| } else if (id.endsWith('_outlined')) { | |
| dartdocHtmlSuffix = '-outlined'; | |
| } else if (id.endsWith('_rounded')) { | |
| dartdocHtmlSuffix = '-round'; | |
| } else if (id.endsWith('_sharp')) { | |
| dartdocHtmlSuffix = '-sharp'; | |
| } | |
| } | |
| _generateShortId(); | |
| _generateFlutterId(); | |
| } | |
| late String id; // e.g. 5g, 5g_outlined, 5g_rounded, 5g_sharp | |
| late String shortId; // e.g. 5g | |
| late String flutterId; // e.g. five_g, five_g_outlined, five_g_rounded, five_g_sharp | |
| late String hexCodepoint; // e.g. e547 | |
| late String dartdocFamily; // e.g. material | |
| late String dartdocHtmlSuffix = ''; // The suffix for the 'material-icons' HTML class. | |
| String fontFamily; // The IconData font family. | |
| List<String> possibleStyleSuffixes; // A list of possible suffixes e.g. _outlined, _monoline_filled. | |
| String className; // The containing class. | |
| String get name => shortId.replaceAll('_', ' ').trim(); | |
| String get style => dartdocHtmlSuffix == '' ? '' : ' (${dartdocHtmlSuffix.replaceFirst('-', '')})'; | |
| String get dartDoc => | |
| '<i class="material-icons$dartdocHtmlSuffix md-36">$shortId</i> — $dartdocFamily icon named "$name"$style'; | |
| String get usage => 'Icon($className.$flutterId),'; | |
| String get mirroredInRTL => _iconsMirroredWhenRTL.contains(shortId) | |
| ? ', matchTextDirection: true' | |
| : ''; | |
| String get declaration => | |
| "static const IconData $flutterId = IconData(0x$hexCodepoint, fontFamily: '$fontFamily'$mirroredInRTL);"; | |
| String get fullDeclaration => ''' | |
| /// $dartDoc. | |
| $declaration | |
| '''; | |
| String platformAdaptiveDeclaration(String fullFlutterId, Icon iOSIcon) => ''' | |
| /// Platform-adaptive icon for $dartDoc and ${iOSIcon.dartDoc}.; | |
| IconData get $fullFlutterId => !_isCupertino() ? $className.$flutterId : $className.${iOSIcon.flutterId}; | |
| '''; | |
| @override | |
| String toString() => id; | |
| /// Analogous to [String.compareTo] | |
| int _compareTo(Icon b) { | |
| if (shortId == b.shortId) { | |
| // Sort a regular icon before its variants. | |
| return id.length - b.id.length; | |
| } | |
| return shortId.compareTo(b.shortId); | |
| } | |
| static String _removeLast(String string, String toReplace) { | |
| return string.replaceAll(RegExp('$toReplace\$'), ''); | |
| } | |
| /// See [shortId]. | |
| void _generateShortId() { | |
| shortId = id; | |
| for (final String styleSuffix in possibleStyleSuffixes) { | |
| shortId = _removeLast(shortId, styleSuffix); | |
| if (shortId != id) { | |
| break; | |
| } | |
| } | |
| } | |
| /// See [flutterId]. | |
| void _generateFlutterId() { | |
| flutterId = id; | |
| // Exact identifier rewrites. | |
| for (final MapEntry<String, String> rewritePair in _identifierExactRewrites.entries) { | |
| if (shortId == rewritePair.key) { | |
| flutterId = id.replaceFirst( | |
| rewritePair.key, | |
| _identifierExactRewrites[rewritePair.key]!, | |
| ); | |
| } | |
| } | |
| // Prefix identifier rewrites. | |
| for (final MapEntry<String, String> rewritePair in _identifierPrefixRewrites.entries) { | |
| if (id.startsWith(rewritePair.key)) { | |
| flutterId = id.replaceFirst( | |
| rewritePair.key, | |
| _identifierPrefixRewrites[rewritePair.key]!, | |
| ); | |
| } | |
| } | |
| // Prevent double underscores. | |
| flutterId = flutterId.replaceAll('__', '_'); | |
| } | |
| } |