| // 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 the material icons file. |
| // See https://github.com/flutter/flutter/wiki/Updating-Material-Design-Fonts-&-Icons |
| |
| 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 _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 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: |
| // http://google.github.io/material-design-icons/#icons-in-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') |
| ..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.className = _defaultClassName, |
| }) { |
| id = tokenPair.key; |
| hexCodepoint = tokenPair.value; |
| |
| // Determine family and htmlSuffix. |
| if (id.endsWith('_gm_outlined')) { |
| family = 'GM'; |
| htmlSuffix = '-outlined'; |
| } else if (id.endsWith('_gm_filled')) { |
| family = 'GM'; |
| htmlSuffix = '-filled'; |
| } else if (id.endsWith('_monoline_outlined')) { |
| family = 'Monoline'; |
| htmlSuffix = '-outlined'; |
| } else if (id.endsWith('_monoline_filled')) { |
| family = 'Monoline'; |
| htmlSuffix = '-filled'; |
| } else { |
| family = 'material'; |
| if (id.endsWith('_baseline')) { |
| id = _removeLast(id, '_baseline'); |
| htmlSuffix = ''; |
| } else if (id.endsWith('_outlined')) { |
| htmlSuffix = '-outlined'; |
| } else if (id.endsWith('_rounded')) { |
| htmlSuffix = '-round'; |
| } else if (id.endsWith('_sharp')) { |
| htmlSuffix = '-sharp'; |
| } |
| } |
| |
| shortId = _generateShortId(id); |
| flutterId = generateFlutterId(id); |
| } |
| |
| static const List<String> _idSuffixes = <String>[ |
| '_gm_outlined', |
| '_gm_filled', |
| '_monoline_outlined', |
| '_monoline_filled', |
| '_outlined', |
| '_rounded', |
| '_sharp', |
| ]; |
| |
| 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 family; // e.g. material |
| late String hexCodepoint; // e.g. e547 |
| late String htmlSuffix = ''; // The suffix for the 'material-icons' HTML class. |
| String fontFamily; // The IconData font family. |
| String className; // The containing class. |
| |
| String get name => shortId.replaceAll('_', ' ').trim(); |
| |
| String get style => htmlSuffix == '' ? '' : ' (${htmlSuffix.replaceFirst('-', '')})'; |
| |
| String get dartDoc => |
| '<i class="material-icons$htmlSuffix md-36">$shortId</i> — $family 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\$'), ''); |
| } |
| |
| static String _generateShortId(String id) { |
| String shortId = id; |
| for (final String styleSuffix in _idSuffixes) { |
| shortId = _removeLast(shortId, styleSuffix); |
| if (shortId != id) { |
| break; |
| } |
| } |
| return shortId; |
| } |
| |
| /// Given some icon's raw id, returns a valid Dart icon identifier |
| static String generateFlutterId(String id) { |
| String flutterId = id; |
| // Exact identifier rewrites. |
| for (final MapEntry<String, String> rewritePair in _identifierExactRewrites.entries) { |
| final String shortId = Icon._generateShortId(id); |
| 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('__', '_'); |
| |
| return flutterId; |
| } |
| } |