feat: embed DUCET collation ranks for alpha-jump sorting

close #498
This commit is contained in:
edde746
2026-02-20 18:05:10 +01:00
parent 57bdb3997e
commit b25df3b411
6 changed files with 712 additions and 124 deletions
File diff suppressed because one or more lines are too long
@@ -649,6 +649,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Whether the device is a phone (not tablet/desktop/TV).
bool _isPhone(BuildContext context) => PlatformDetector.isPhone(context);
/// The letter currently visible at the top of the grid, determined by
/// how many items we've scrolled past relative to the API's cumulative
/// firstCharacter counts.
String get _currentAlphaLetter {
return _alphaHelper.currentLetter(_currentFirstVisibleIndex);
}
/// Whether the alpha jump bar should be shown.
/// Only shown when sorting by title (titleSort) and not in folders mode.
bool get _shouldShowAlphaJumpBar {
@@ -736,51 +743,23 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
/// Scroll to the item at [targetIndex], loading more pages if necessary.
/// Pins the index so scroll events during the animation don't override
/// the highlighted letter. The pin is cleared on the next user scroll.
///
/// The [targetIndex] comes from [AlphaJumpHelper] which derives cumulative
/// indices from the `firstCharacters` API. That API may count by display
/// title (e.g. "The Simpsons" → T), while the grid sorts by `titleSort`
/// (e.g. "Simpsons" → S). We correct for this by searching the loaded
/// items' `titleSort` for the true start of the target letter.
/// The target index is a cumulative offset from the API's firstCharacter
/// counts — the same model used by [_currentAlphaLetter] so highlight
/// and jump always agree.
void _jumpToIndex(int targetIndex) {
_jumpScrollGeneration++;
_isJumpScrolling = true;
// Determine the intended letter from the helper's model
final targetLetter = _alphaHelper.currentLetter(targetIndex);
// Correct the index using actual items' titleSort when available
final correctedIndex = _findFirstItemForLetter(targetLetter) ?? targetIndex;
_hasJumpPin = true;
setState(() => _currentFirstVisibleIndex = correctedIndex);
setState(() => _currentFirstVisibleIndex = targetIndex);
if (correctedIndex < items.length) {
_scrollToItemIndex(correctedIndex);
if (targetIndex < items.length) {
_scrollToItemIndex(targetIndex);
} else {
_loadUntilIndex(correctedIndex);
_loadUntilIndex(targetIndex);
}
}
/// Returns the first character (A-Z or #) of an item's sort title.
static String _sortTitleFirstChar(String title) {
if (title.isEmpty) return '#';
final ch = title[0].toUpperCase();
return ch.codeUnitAt(0) >= 65 && ch.codeUnitAt(0) <= 90 ? ch : '#';
}
/// Find the index of the first loaded item whose `titleSort` starts with
/// [letter]. Returns null if no match is found (e.g. items not yet loaded).
int? _findFirstItemForLetter(String letter) {
for (int i = 0; i < items.length; i++) {
final sortTitle = items[i].titleSort ?? items[i].title;
if (_sortTitleFirstChar(sortTitle) == letter) return i;
}
return null;
}
/// Scroll the grid so that [index] is visible just below the chips bar
void _scrollToItemIndex(int index) {
if (_currentColumnCount < 1 || _lastCrossAxisExtent <= 0 || !_scrollController.hasClients) {
@@ -867,13 +846,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
? AlphaScrollHandle(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
currentLetter: _currentAlphaLetter,
isScrolling: _isScrollActive,
)
: AlphaJumpBar(
firstCharacters: _firstCharacters,
onJump: _jumpToIndex,
currentFirstVisibleIndex: _currentFirstVisibleIndex,
currentLetter: _currentAlphaLetter,
focusNode: _alphaJumpBarFocusNode,
onNavigateLeft: _navigateToGridNearScroll,
onBack: _navigateToGridNearScroll,
+71 -23
View File
@@ -6,15 +6,20 @@ import 'package:flutter/services.dart';
import '../models/plex_first_character.dart';
import 'alpha_jump_helper.dart';
/// Vertical strip of letters (#, AZ) for jumping through sorted library items.
/// Vertical strip of letters for jumping through sorted library items.
///
/// Pre-computes a cumulative index map from [firstCharacters] data so that
/// tapping a letter triggers [onJump] with the item index where that letter
/// begins. Supports both touch (tap/drag) and D-pad (up/down/select) input.
/// begins. When more letters exist than fit vertically, the bar keeps the
/// highest-count letters (by item size) and drops the rest.
/// Supports both touch (tap/drag) and D-pad (up/down/select) input.
class AlphaJumpBar extends StatefulWidget {
final List<PlexFirstCharacter> firstCharacters;
final void Function(int targetIndex) onJump;
final int currentFirstVisibleIndex;
/// The letter currently visible at the top of the grid, derived from the
/// actual item's sort title by the parent widget.
final String currentLetter;
final FocusNode? focusNode;
final VoidCallback? onNavigateLeft;
final VoidCallback? onBack;
@@ -23,7 +28,7 @@ class AlphaJumpBar extends StatefulWidget {
super.key,
required this.firstCharacters,
required this.onJump,
required this.currentFirstVisibleIndex,
required this.currentLetter,
this.focusNode,
this.onNavigateLeft,
this.onBack,
@@ -36,6 +41,12 @@ class AlphaJumpBar extends StatefulWidget {
class _AlphaJumpBarState extends State<AlphaJumpBar> {
late AlphaJumpHelper _helper;
/// Subset of letters actually rendered, filtered by available height.
List<String> _displayed = const [];
/// Cached max-letter count from the last layout pass.
int _lastMaxLetters = -1;
/// Currently highlighted letter index (for D-pad navigation).
int _highlightedIndex = 0;
@@ -45,10 +56,14 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
/// Debounce timer for keyboard-driven jumps.
Timer? _debounce;
/// Minimum vertical space per letter slot.
static const double _minLetterHeight = 20.0;
@override
void initState() {
super.initState();
_helper = AlphaJumpHelper(widget.firstCharacters);
_displayed = _helper.letters;
}
@override
@@ -56,6 +71,9 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
super.didUpdateWidget(oldWidget);
if (oldWidget.firstCharacters != widget.firstCharacters) {
_helper = AlphaJumpHelper(widget.firstCharacters);
_lastMaxLetters = -1; // force recompute in next layout
_displayed = _helper.letters;
_clampHighlight();
}
}
@@ -65,6 +83,36 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
super.dispose();
}
void _clampHighlight() {
if (_displayed.isNotEmpty) {
_highlightedIndex = _highlightedIndex.clamp(0, _displayed.length - 1);
} else {
_highlightedIndex = 0;
}
}
/// Recompute [_displayed] if the available letter count changed.
void _updateDisplayed(double availableHeight) {
final maxLetters = (availableHeight / _minLetterHeight).floor();
if (maxLetters == _lastMaxLetters) return;
_lastMaxLetters = maxLetters;
_displayed = _helper.displayLetters(maxLetters);
_clampHighlight();
}
/// Find the nearest displayed letter at or before [letter] in the full list.
String _nearestDisplayed(String letter) {
if (_displayed.contains(letter)) return letter;
final pos = _helper.letters.indexOf(letter);
if (pos < 0 && _displayed.isNotEmpty) return _displayed.first;
String result = _displayed.first;
for (final dl in _displayed) {
final dlPos = _helper.letters.indexOf(dl);
if (dlPos <= pos) result = dl;
}
return result;
}
void _jumpToLetter(String letter) {
final index = _helper.indexForLetter(letter);
if (index != null) {
@@ -73,19 +121,19 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
}
/// Schedule a debounced jump to the currently highlighted letter.
/// The highlight updates immediately for visual feedback, but the
/// actual scroll only fires after keyboard input settles.
void _debouncedJump() {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 150), () {
_jumpToLetter(AlphaJumpHelper.allLetters[_highlightedIndex]);
if (_highlightedIndex < _displayed.length) {
_jumpToLetter(_displayed[_highlightedIndex]);
}
});
}
/// Resolves a vertical drag position to a letter index.
/// Resolves a vertical drag position to a displayed-letter index.
int _letterIndexFromDy(double dy, double totalHeight) {
final index = (dy / totalHeight * AlphaJumpHelper.allLetters.length).floor();
return index.clamp(0, AlphaJumpHelper.allLetters.length - 1);
final index = (dy / totalHeight * _displayed.length).floor();
return index.clamp(0, _displayed.length - 1);
}
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
@@ -101,7 +149,7 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (_highlightedIndex < AlphaJumpHelper.allLetters.length - 1) {
if (_highlightedIndex < _displayed.length - 1) {
setState(() => _highlightedIndex++);
_debouncedJump();
}
@@ -123,7 +171,6 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
@override
Widget build(BuildContext context) {
final currentLetter = _helper.currentLetter(widget.currentFirstVisibleIndex);
final colorScheme = Theme.of(context).colorScheme;
return Focus(
@@ -133,26 +180,30 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
setState(() {
_hasFocus = hasFocus;
if (hasFocus) {
// Start highlight at the current letter when gaining focus
final idx = AlphaJumpHelper.allLetters.indexOf(currentLetter);
final displayed = _nearestDisplayed(widget.currentLetter);
final idx = _displayed.indexOf(displayed);
if (idx >= 0) _highlightedIndex = idx;
}
});
},
child: LayoutBuilder(
builder: (context, constraints) {
_updateDisplayed(constraints.maxHeight);
final currentLetter = _nearestDisplayed(widget.currentLetter);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) {
final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight);
setState(() => _highlightedIndex = idx);
_jumpToLetter(AlphaJumpHelper.allLetters[idx]);
_jumpToLetter(_displayed[idx]);
},
onVerticalDragUpdate: (details) {
final idx = _letterIndexFromDy(details.localPosition.dy, constraints.maxHeight);
if (idx != _highlightedIndex) {
setState(() => _highlightedIndex = idx);
_jumpToLetter(AlphaJumpHelper.allLetters[idx]);
_jumpToLetter(_displayed[idx]);
}
},
child: Container(
@@ -163,9 +214,8 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(AlphaJumpHelper.allLetters.length, (i) {
final letter = AlphaJumpHelper.allLetters[i];
final isActive = _helper.activeLetters.contains(letter);
children: List.generate(_displayed.length, (i) {
final letter = _displayed[i];
final isCurrent = letter == currentLetter && !_hasFocus;
final isHighlighted = _hasFocus && i == _highlightedIndex;
@@ -184,14 +234,12 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
letterColor = colorScheme.onPrimary;
} else if (isCurrent) {
letterColor = colorScheme.primary;
} else if (isActive) {
letterColor = colorScheme.onSurface;
} else {
letterColor = colorScheme.onSurface.withValues(alpha: 0.25);
letterColor = colorScheme.onSurface;
}
return SizedBox(
height: constraints.maxHeight / AlphaJumpHelper.allLetters.length,
height: constraints.maxHeight / _displayed.length,
child: Center(
child: Container(
width: 22,
+54 -57
View File
@@ -1,83 +1,79 @@
import '../data/ducet_order.dart';
import '../models/plex_first_character.dart';
/// Shared letter-index mapping logic used by both [AlphaJumpBar] (desktop/tablet/TV)
/// and [AlphaScrollHandle] (phone).
///
/// Builds a cumulative index map from [PlexFirstCharacter] data and provides
/// fraction-based mapping for proportional scroll handle positioning.
/// Builds a dynamic letter list and cumulative index map from [PlexFirstCharacter]
/// data returned by the Plex API. Only letters that have items are included,
/// supporting non-Latin scripts (Korean, Japanese, Cyrillic, etc.).
///
/// The API's firstCharacter endpoint returns characters in Unicode codepoint
/// order, which doesn't match the content endpoint's ICU locale-aware sort.
/// We re-sort using ICU collation so cumulative indices are correct.
class AlphaJumpHelper {
static const List<String> allLetters = [
'#',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
/// Dynamic letter list derived from the API's firstCharacter data,
/// re-sorted to match ICU collation order.
final List<String> letters;
/// Maps each letter to its cumulative start index in the full item list.
final Map<String, int> letterToIndex;
/// Letters that have at least one item in the data.
final Set<String> activeLetters;
/// Maps each letter to its item count.
final Map<String, int> letterSizes;
/// Total number of items across all letters.
final int totalItemCount;
AlphaJumpHelper._(this.letterToIndex, this.activeLetters, this.totalItemCount);
AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount);
factory AlphaJumpHelper(List<PlexFirstCharacter> firstCharacters) {
// Build a lookup from the API data. Note: the firstCharacters API may
// count by display title (e.g. "The Simpsons" → T) rather than titleSort
// ("Simpsons" → S), so cumulative indices are approximate. The browse tab
// corrects for this when jumping by searching loaded items' titleSort.
final sizeByLetter = <String, int>{};
// Collect characters with their sizes.
final entries = <({String letter, int size})>[];
final letterSizes = <String, int>{};
for (final fc in firstCharacters) {
sizeByLetter[fc.title.toUpperCase()] = fc.size;
}
// Compute cumulative indices in allLetters order (#, A, B, …, Z).
final letterToIndex = <String, int>{};
final activeLetters = <String>{};
int cumulative = 0;
for (final letter in allLetters) {
final size = sizeByLetter[letter];
if (size != null && size > 0) {
activeLetters.add(letter);
letterToIndex[letter] = cumulative;
cumulative += size;
final letter = fc.title.toUpperCase();
if (fc.size > 0) {
entries.add((letter: letter, size: fc.size));
letterSizes[letter] = fc.size;
}
}
return AlphaJumpHelper._(letterToIndex, activeLetters, cumulative);
// Re-sort by DUCET collation to match the content endpoint's ICU sort order.
entries.sort((a, b) => ducetCompare(a.letter, b.letter));
// Build cumulative index map in the corrected order.
final letters = <String>[];
final letterToIndex = <String, int>{};
int cumulative = 0;
for (final e in entries) {
letters.add(e.letter);
letterToIndex[e.letter] = cumulative;
cumulative += e.size;
}
return AlphaJumpHelper._(letters, letterToIndex, letterSizes, cumulative);
}
/// Returns at most [maxCount] letters, prioritizing those with the most
/// items. The returned letters maintain their original order.
List<String> displayLetters(int maxCount) {
if (maxCount >= letters.length) return letters;
if (maxCount <= 0) return const [];
final indices = List.generate(letters.length, (i) => i);
indices.sort((a, b) => (letterSizes[letters[b]] ?? 0).compareTo(letterSizes[letters[a]] ?? 0));
final kept = indices.take(maxCount).toList()..sort();
return [for (final i in kept) letters[i]];
}
/// Returns the letter that the given item index falls within.
String currentLetter(int itemIndex) {
String current = allLetters.first;
for (final letter in allLetters) {
if (letters.isEmpty) return '#';
String current = letters.first;
for (final letter in letters) {
final startIndex = letterToIndex[letter];
if (startIndex != null && startIndex <= itemIndex) {
current = letter;
@@ -101,7 +97,8 @@ class AlphaJumpHelper {
/// Returns the letter at a given fractional position (0.01.0), proportional
/// to item count.
String letterAtFraction(double fraction) {
if (totalItemCount == 0) return allLetters.first;
if (letters.isEmpty) return '#';
if (totalItemCount == 0) return letters.first;
final targetIndex = (fraction * totalItemCount).round().clamp(0, totalItemCount - 1);
return currentLetter(targetIndex);
}
+8 -7
View File
@@ -13,7 +13,10 @@ import 'alpha_jump_helper.dart';
class AlphaScrollHandle extends StatefulWidget {
final List<PlexFirstCharacter> firstCharacters;
final void Function(int targetIndex) onJump;
final int currentFirstVisibleIndex;
/// The letter currently visible at the top of the grid, derived from the
/// actual item's sort title by the parent widget.
final String currentLetter;
/// Whether the parent scroll view is currently scrolling.
final bool isScrolling;
@@ -22,7 +25,7 @@ class AlphaScrollHandle extends StatefulWidget {
super.key,
required this.firstCharacters,
required this.onJump,
required this.currentFirstVisibleIndex,
required this.currentLetter,
required this.isScrolling,
});
@@ -104,11 +107,10 @@ class _AlphaScrollHandleState extends State<AlphaScrollHandle> with SingleTicker
}
void _onDragStart(DragStartDetails _) {
final currentLetter = _helper.currentLetter(widget.currentFirstVisibleIndex);
setState(() {
_isDragging = true;
_dragFraction = _helper.fractionForLetter(currentLetter);
_dragLetter = currentLetter;
_dragFraction = _helper.fractionForLetter(widget.currentLetter);
_dragLetter = widget.currentLetter;
});
_hideTimer?.cancel();
_show();
@@ -152,10 +154,9 @@ class _AlphaScrollHandleState extends State<AlphaScrollHandle> with SingleTicker
final trackHeight = constraints.maxHeight;
_trackHeight = trackHeight;
final currentLetter = _helper.currentLetter(widget.currentFirstVisibleIndex);
final fraction = _isDragging && _dragFraction != null
? _dragFraction!
: _helper.fractionForLetter(currentLetter);
: _helper.fractionForLetter(widget.currentLetter);
final usableHeight = trackHeight - _handleHeight;
final handleTop = usableHeight > 0 ? (fraction * usableHeight) : 0.0;
+317
View File
@@ -0,0 +1,317 @@
/// Generates lib/data/ducet_order.dart from Unicode allkeys.txt and CLDR FractionalUCA.txt.
///
/// Usage:
/// dart run scripts/generate_ducet_ranks.dart [allkeys.txt] [FractionalUCA.txt]
///
/// Downloads the files automatically if not provided.
import 'dart:io';
// ---------------------------------------------------------------------------
// Weight tuple for multi-level UCA comparison
// ---------------------------------------------------------------------------
class _Weight implements Comparable<_Weight> {
final List<(int, int, int)> levels;
final int codepoint; // tiebreaker
const _Weight(this.levels, this.codepoint);
@override
int compareTo(_Weight other) {
final len = levels.length < other.levels.length ? levels.length : other.levels.length;
// Primary pass
for (var i = 0; i < len; i++) {
final cmp = levels[i].$1.compareTo(other.levels[i].$1);
if (cmp != 0) return cmp;
}
if (levels.length != other.levels.length) {
return levels.length.compareTo(other.levels.length);
}
// Secondary pass
for (var i = 0; i < len; i++) {
final cmp = levels[i].$2.compareTo(other.levels[i].$2);
if (cmp != 0) return cmp;
}
// Tertiary pass
for (var i = 0; i < len; i++) {
final cmp = levels[i].$3.compareTo(other.levels[i].$3);
if (cmp != 0) return cmp;
}
// Codepoint tiebreaker
return codepoint.compareTo(other.codepoint);
}
}
// ---------------------------------------------------------------------------
// Katakana codepoint ranges (for CLDR kana tertiary reversal)
// ---------------------------------------------------------------------------
bool _isKatakana(int cp) =>
(cp >= 0x30A0 && cp <= 0x30FF) || // Katakana
(cp >= 0x31F0 && cp <= 0x31FF) || // Katakana Phonetic Extensions
(cp >= 0xFF65 && cp <= 0xFF9F); // Halfwidth Katakana
// ---------------------------------------------------------------------------
// Parse allkeys.txt → Map<codepoint, weight-tuples>
//
// CLDR kana fix: ICU/CLDR root collation sorts katakana before hiragana
// (opposite of raw DUCET). We adjust by subtracting 6 from katakana tertiary
// weights, placing them below hiragana tertiaries (0x000D+).
// ---------------------------------------------------------------------------
Map<int, _Weight> parseAllKeys(String text) {
final result = <int, _Weight>{};
final weightRe = RegExp(r'\[([.*])([0-9A-Fa-f]{4})\.([0-9A-Fa-f]{4})\.([0-9A-Fa-f]{4})\]');
for (final line in text.split('\n')) {
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#') || trimmed.startsWith('@')) continue;
final semiIdx = trimmed.indexOf(';');
if (semiIdx < 0) continue;
final cpPart = trimmed.substring(0, semiIdx).trim();
if (cpPart.contains(' ')) continue; // skip multi-codepoint
final cp = int.tryParse(cpPart, radix: 16);
if (cp == null || cp > 0xFFFF) continue; // BMP only
final weightPart = trimmed.substring(semiIdx + 1);
final matches = weightRe.allMatches(weightPart);
if (matches.isEmpty) continue;
final isKat = _isKatakana(cp);
final levels = <(int, int, int)>[];
for (final m in matches) {
final p = int.parse(m.group(2)!, radix: 16);
final s = int.parse(m.group(3)!, radix: 16);
var t = int.parse(m.group(4)!, radix: 16);
// CLDR kana fix: lower katakana tertiary below hiragana range
if (isKat && t >= 0x000F) t -= 6;
levels.add((p, s, t));
}
if (levels.every((l) => l.$1 == 0 && l.$2 == 0 && l.$3 == 0)) continue;
result[cp] = _Weight(levels, cp);
}
return result;
}
// ---------------------------------------------------------------------------
// Parse FractionalUCA.txt radical lines → CJK codepoints in radical-stroke
// order, plus Kangxi radical → CJK decomposition map.
// ---------------------------------------------------------------------------
(List<int>, Map<int, int>) parseRadicals(String text) {
final order = <int>[];
final kangxiDecomp = <int, int>{}; // kangxi radical CP → CJK CP
final radicalRe = RegExp(r'^\[radical \d+');
for (final line in text.split('\n')) {
if (!radicalRe.hasMatch(line)) continue;
// Parse header: [radical N=<kangxi><cjk>:<char-list>]
final eqIdx = line.indexOf('=');
final colonIdx = line.indexOf(':');
if (eqIdx < 0 || colonIdx < 0 || colonIdx <= eqIdx) continue;
final closeBracket = line.lastIndexOf(']');
if (closeBracket < 0) continue;
// Extract Kangxi radical → CJK mapping from header
final headerChars = line.substring(eqIdx + 1, colonIdx).runes.toList();
if (headerChars.length >= 2) {
final kangxi = headerChars[0];
final cjk = headerChars[1];
// Kangxi Radicals: U+2F00-U+2FD5
if (kangxi >= 0x2F00 && kangxi <= 0x2FD5 && cjk <= 0xFFFF) {
kangxiDecomp[kangxi] = cjk;
}
}
// Parse character list after colon
final charList = line.substring(colonIdx + 1, closeBracket);
final runes = charList.runes.toList();
var i = 0;
while (i < runes.length) {
final cp = runes[i];
if (cp == 0x20) {
i++;
continue;
}
// Check for range: <char>-<char>
if (i + 2 < runes.length && runes[i + 1] == 0x2D) {
final endCp = runes[i + 2];
for (var c = cp; c <= endCp; c++) {
if (c <= 0xFFFF) order.add(c);
}
i += 3;
continue;
}
if (cp <= 0xFFFF) order.add(cp);
i++;
}
}
// Deduplicate preserving order
final seen = <int>{};
final deduped = order.where((cp) => seen.add(cp)).toList();
return (deduped, kangxiDecomp);
}
// ---------------------------------------------------------------------------
// Build final ordered list
// ---------------------------------------------------------------------------
List<int> buildOrder(Map<int, _Weight> allKeys, List<int> cjkRadicalOrder) {
final entries = allKeys.entries.toList();
entries.sort((a, b) => a.value.compareTo(b.value));
final ordered = <int>[];
final cjkSet = cjkRadicalOrder.toSet();
bool cjkInserted = false;
for (final e in entries) {
if (cjkSet.contains(e.key)) continue;
if (!cjkInserted && e.value.levels.isNotEmpty && e.value.levels.first.$1 >= 0xFB00) {
for (final cp in cjkRadicalOrder) {
if (cp <= 0xFFFF) ordered.add(cp);
}
cjkInserted = true;
}
ordered.add(e.key);
}
if (!cjkInserted) {
for (final cp in cjkRadicalOrder) {
if (cp <= 0xFFFF) ordered.add(cp);
}
}
return ordered;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
Future<void> main(List<String> args) async {
final allKeysPath = args.length > 0 ? args[0] : '/tmp/allkeys.txt';
final fracUcaPath = args.length > 1 ? args[1] : '/tmp/FractionalUCA.txt';
// Download if missing
if (!File(allKeysPath).existsSync()) {
stderr.writeln('Downloading allkeys.txt...');
final result = await Process.run('curl', [
'-sL',
'https://www.unicode.org/Public/UCA/13.0.0/allkeys.txt',
'-o',
allKeysPath,
]);
if (result.exitCode != 0) {
stderr.writeln('Failed to download allkeys.txt');
exit(1);
}
}
if (!File(fracUcaPath).existsSync()) {
stderr.writeln('Downloading FractionalUCA.txt...');
final result = await Process.run('curl', [
'-sL',
'https://raw.githubusercontent.com/unicode-org/cldr/release-39/common/uca/FractionalUCA.txt',
'-o',
fracUcaPath,
]);
if (result.exitCode != 0) {
stderr.writeln('Failed to download FractionalUCA.txt');
exit(1);
}
}
stderr.writeln('Parsing allkeys.txt (with CLDR kana fix)...');
final allKeysText = File(allKeysPath).readAsStringSync();
final allKeys = parseAllKeys(allKeysText);
stderr.writeln(' ${allKeys.length} BMP entries');
stderr.writeln('Parsing FractionalUCA.txt...');
final fracText = File(fracUcaPath).readAsStringSync();
final (cjkOrder, kangxiDecomp) = parseRadicals(fracText);
stderr.writeln(' ${cjkOrder.length} CJK codepoints in radical-stroke order');
stderr.writeln(' ${kangxiDecomp.length} Kangxi radical decompositions');
final bmpCjk = cjkOrder.where((cp) => cp <= 0xFFFF).length;
stderr.writeln(' $bmpCjk BMP CJK codepoints');
stderr.writeln('Building total order...');
final ordered = buildOrder(allKeys, cjkOrder);
stderr.writeln(' ${ordered.length} total BMP codepoints in order');
stderr.writeln(' ${ordered.length * 2} raw bytes');
// Generate Dart file — store codepoints as a raw UTF-16 string constant.
// Each char IS the codepoint. Dart strings are UTF-16 natively, so the
// compiled snapshot stores the data as raw 2-byte values with zero decoding.
final outPath = '${Directory.current.path}/lib/data/ducet_order.dart';
final buf = StringBuffer();
buf.writeln('/// Sorted BMP codepoints per DUCET (Unicode 13.0) + CLDR 39 CJK radical-stroke.');
buf.writeln('/// Katakana sorts before hiragana (CLDR root tailoring).');
buf.writeln('/// Generated by scripts/generate_ducet_ranks.dart — do not edit.');
buf.writeln();
// Raw UTF-16 string — each code unit is a codepoint in DUCET order.
// Use \uXXXX escapes so the source stays ASCII-safe.
buf.write("const String _ducetOrder = '");
for (var i = 0; i < ordered.length; i++) {
final cp = ordered[i];
if (cp == 0x27) {
buf.write(r"\'"); // escape single quote
} else if (cp == 0x5C) {
buf.write(r'\\'); // escape backslash
} else if (cp == 0x24) {
buf.write(r'\$'); // escape dollar
} else {
buf.write('\\u${cp.toRadixString(16).padLeft(4, '0')}');
}
}
buf.writeln("';");
// Kangxi radical decomposition map
buf.writeln();
buf.writeln('/// Kangxi Radicals (U+2F00-U+2FD5) → CJK Unified equivalents.');
buf.writeln('/// ICU decomposes these via NFD before collation.');
buf.writeln('const Map<int, int> _kangxiDecomp = {');
final sortedKangxi = kangxiDecomp.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
for (final e in sortedKangxi) {
buf.writeln(' 0x${e.key.toRadixString(16).toUpperCase()}: 0x${e.value.toRadixString(16).toUpperCase()},');
}
buf.writeln('};');
buf.writeln();
buf.writeln('late final Map<int, int> _ranks = _buildRanks();');
buf.writeln();
buf.writeln('Map<int, int> _buildRanks() {');
buf.writeln(' return {');
buf.writeln(' for (var i = 0; i < _ducetOrder.length; i++)');
buf.writeln(' _ducetOrder.codeUnitAt(i): i,');
buf.writeln(' };');
buf.writeln('}');
buf.writeln();
buf.writeln('/// Compare two characters using DUCET + CLDR ordering.');
buf.writeln('/// Decomposes Kangxi radicals to CJK equivalents before lookup.');
buf.writeln('/// Falls back to codepoint order for characters not in the table.');
buf.writeln('int ducetCompare(String a, String b) {');
buf.writeln(' var cpA = a.runes.first;');
buf.writeln(' var cpB = b.runes.first;');
buf.writeln(' cpA = _kangxiDecomp[cpA] ?? cpA;');
buf.writeln(' cpB = _kangxiDecomp[cpB] ?? cpB;');
buf.writeln(' final rankA = _ranks[cpA] ?? (0x100000 + cpA);');
buf.writeln(' final rankB = _ranks[cpB] ?? (0x100000 + cpB);');
buf.writeln(' return rankA.compareTo(rankB);');
buf.writeln('}');
File(outPath).writeAsStringSync(buf.toString());
stderr.writeln('Written to $outPath');
}