@ciphore/radiocli 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/CONTRIBUTING.md +28 -0
  3. package/README.md +108 -253
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +182 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +130 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +314 -76
  71. package/dist/types.js +22 -1
  72. package/dist/ui/AdaptiveContent.js +21 -24
  73. package/dist/ui/App.js +708 -454
  74. package/dist/ui/AppContent.js +5 -5
  75. package/dist/ui/airplay-settings.js +12 -5
  76. package/dist/ui/bidi-ranges.js +45 -0
  77. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  78. package/dist/ui/components/ReceiverVisualRow.js +39 -0
  79. package/dist/ui/format.js +37 -4
  80. package/dist/ui/help-content.js +5 -2
  81. package/dist/ui/rtl-output.js +137 -0
  82. package/dist/ui/screen-items.js +10 -4
  83. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  84. package/dist/ui/screens/AlarmsScreen.js +5 -2
  85. package/dist/ui/screens/HelpScreen.js +3 -3
  86. package/dist/ui/screens/NowPlayingScreen.js +2 -21
  87. package/dist/ui/screens/SettingsScreen.js +16 -16
  88. package/dist/ui/screens/StatsScreen.js +1 -1
  89. package/dist/ui/text-direction.js +9 -0
  90. package/dist/ui/theme.js +46 -0
  91. package/dist/ui/use-alarm-tui.js +24 -24
  92. package/dist/ui/use-app-input.js +42 -8
  93. package/dist/ui/use-command-executor.js +21 -14
  94. package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
  95. package/dist/ui/visualizers/boat-lake.js +161 -0
  96. package/dist/ui/visualizers/gallop.js +2 -2
  97. package/dist/ui/visualizers/receiver-style-registry.js +3 -1
  98. package/dist/ui/visualizers/receiver-visualizers.js +6 -108
  99. package/dist/ui/visualizers/sumi-ocean.js +183 -0
  100. package/dist/ui/visualizers/terminal-cell.js +45 -0
  101. package/dist/update-check.js +2 -1
  102. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  103. package/docs/boat-lake.md +88 -0
  104. package/docs/sumi-ocean.md +80 -0
  105. package/package.json +36 -12
  106. package/scripts/build-airplay-native.mjs +22 -0
  107. package/vendor/node-airtunes2/LICENSE +208 -0
  108. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  109. package/vendor/node-airtunes2/README.md +225 -0
  110. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  111. package/vendor/node-airtunes2/binding.gyp +41 -0
  112. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  113. package/vendor/node-airtunes2/lib/atv.js +307 -0
  114. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  115. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  116. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  117. package/vendor/node-airtunes2/lib/config.js +26 -0
  118. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  119. package/vendor/node-airtunes2/lib/devices.js +266 -0
  120. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  121. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  122. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  123. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  124. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  125. package/vendor/node-airtunes2/lib/http.js +189 -0
  126. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  127. package/vendor/node-airtunes2/lib/index.js +127 -0
  128. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  129. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  130. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  131. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  132. package/vendor/node-airtunes2/lib/srp.js +158 -0
  133. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  134. package/vendor/node-airtunes2/lib/util.js +12 -0
  135. package/vendor/node-airtunes2/package.json +31 -0
  136. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  137. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  138. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  139. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  140. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  141. package/vendor/node-airtunes2/src/base64.c +136 -0
  142. package/vendor/node-airtunes2/src/base64.h +42 -0
  143. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  144. package/vendor/node-airtunes2/src/codec.cc +170 -0
  145. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -15,7 +15,7 @@ import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
15
15
  import { selectedAirPlayDevice } from './airplay-settings.js';
16
16
  import { AdaptiveContent } from './AdaptiveContent.js';
17
17
  import { AlarmsScreen, AlarmEditorScreen, AlarmPickerScreen, AlarmRingingScreen } from './screens/AlarmsScreen.js';
18
- export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, settingsPage, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui, airPlaySupported = true }) {
18
+ export function AppContent({ platform = process.platform, airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, settingsPage, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui, airPlaySupported = true }) {
19
19
  if (layout.compact) {
20
20
  if (screen === 'alarms')
21
21
  return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
@@ -25,7 +25,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
25
25
  return _jsx(AlarmPickerScreen, { choices: alarmTui.pickerChoices, selected: selected, fallback: alarmTui.pickerFallback, theme: theme, width: frameWidth, height: layout.contentRows });
26
26
  if (screen === 'alarm-ringing')
27
27
  return _jsx(AlarmRingingScreen, { sessions: alarmTui.activeAlarms, alarms: library.alarms, selected: alarmTui.activeSelected, snoozeMinutes: alarmTui.snoozeMinutes, theme: theme, width: frameWidth, height: layout.contentRows });
28
- return (_jsx(AdaptiveContent, { mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, settingsPage: settingsPage, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, appVersion: appVersion, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel, airPlaySupported: airPlaySupported }));
28
+ return (_jsx(AdaptiveContent, { platform: platform, mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, settingsPage: settingsPage, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, appVersion: appVersion, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel, airPlaySupported: airPlaySupported }));
29
29
  }
30
30
  if (screen === 'alarms')
31
31
  return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
@@ -60,16 +60,16 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
60
60
  return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
61
61
  }
62
62
  if (screen === 'settings') {
63
- return (_jsx(SettingsScreen, { page: settingsPage, selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows, airPlaySupported: airPlaySupported }));
63
+ return (_jsx(SettingsScreen, { platform: platform, page: settingsPage, selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows, airPlaySupported: airPlaySupported }));
64
64
  }
65
65
  if (screen === 'airplay-settings') {
66
- return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth, height: layout.contentRows }));
66
+ return (_jsx(AirPlaySettingsScreen, { platform: platform, selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth, height: layout.contentRows }));
67
67
  }
68
68
  if (screen === 'airplay-code') {
69
69
  return (_jsx(AirPlayCodeScreen, { code: airPlayCode, playback: playback, selectedDevice: selectedAirPlayDevice(library.settings, airPlayDevices), theme: theme, width: frameWidth }));
70
70
  }
71
71
  if (screen === 'help') {
72
- return _jsx(HelpScreen, { theme: theme, width: frameWidth, height: layout.contentRows, selected: selected });
72
+ return _jsx(HelpScreen, { airPlaySupported: airPlaySupported, theme: theme, width: frameWidth, height: layout.contentRows, selected: selected });
73
73
  }
74
74
  return _jsx(Text, { children: "Unknown screen." });
75
75
  }
@@ -1,15 +1,18 @@
1
+ import { airPlayLocalDeviceLabel } from '../player/backend-install.js';
1
2
  export function isAirPlayBackendAvailable(backends) {
2
3
  return backends.includes('airplay');
3
4
  }
4
5
  export function selectedAirPlayDevice(settings, devices) {
5
6
  return devices.find(device => device.id === settings.preferredAirPlayDevice);
6
7
  }
7
- export function airPlayReceiverSettingValue(settings, devices, backends) {
8
+ export function airPlayReceiverSettingValue(settings, devices, backends, platform = process.platform) {
9
+ if (platform === 'linux' && !isAirPlayBackendAvailable(backends))
10
+ return 'Setup required';
8
11
  const device = selectedAirPlayDevice(settings, devices);
9
12
  const count = airPlayDeviceCountLabel(devices.length);
10
13
  const playbackStatus = isAirPlayBackendAvailable(backends) ? undefined : 'playback unavailable';
11
14
  if (device) {
12
- return [device.name, device.local ? 'this Mac' : playbackStatus ?? count].join(' · ');
15
+ return [device.name, device.local ? airPlayLocalDeviceLabel(platform) : playbackStatus ?? count].join(' · ');
13
16
  }
14
17
  if (settings.preferredAirPlayDevice) {
15
18
  return ['Saved receiver missing', playbackStatus ?? count].join(' · ');
@@ -19,8 +22,10 @@ export function airPlayReceiverSettingValue(settings, devices, backends) {
19
22
  }
20
23
  return playbackStatus ? `No receivers found · ${playbackStatus}` : 'No receivers found';
21
24
  }
22
- export function airPlayAvailability(backends, devices) {
25
+ export function airPlayAvailability(backends, devices, platform = process.platform) {
23
26
  if (!isAirPlayBackendAvailable(backends)) {
27
+ if (platform === 'linux')
28
+ return { ready: false, label: 'Setup required', detail: 'Run radiocli setup --airplay, then radiocli doctor to check FFmpeg, Avahi, and the optional sender.' };
24
29
  const detail = devices.length > 0
25
30
  ? 'RadioCLI can see receivers, but this install cannot start AirPlay yet. Run radiocli doctor.'
26
31
  : 'AirPlay is not ready on this install. Run radiocli doctor.';
@@ -31,6 +36,8 @@ export function airPlayAvailability(backends, devices) {
31
36
  };
32
37
  }
33
38
  if (devices.length === 0) {
39
+ if (platform === 'linux')
40
+ return { ready: false, label: 'Unverified', detail: 'Refresh to find IPv4 receivers and verify experimental AirPlay. Select a receiver each session.' };
34
41
  return {
35
42
  ready: true,
36
43
  label: 'Ready',
@@ -43,9 +50,9 @@ export function airPlayAvailability(backends, devices) {
43
50
  detail: `${airPlayDeviceCountLabel(devices.length)} visible on this network.`
44
51
  };
45
52
  }
46
- export function airPlayDeviceDetail(device) {
53
+ export function airPlayDeviceDetail(device, platform = process.platform) {
47
54
  if (device.local) {
48
- return `${device.host}:${device.port} · this Mac · use local output`;
55
+ return `${device.host}:${device.port} · ${airPlayLocalDeviceLabel(platform)} · use local output`;
49
56
  }
50
57
  const security = device.requiresPassword ? 'code shown on receiver' : 'no code';
51
58
  const protocol = device.airplay2 ? 'AirPlay 2' : 'AirPlay';
@@ -0,0 +1,45 @@
1
+ // Generated by scripts/generate-bidi-ranges.mjs; do not edit.
2
+ // Unicode 17.0.0: https://www.unicode.org/Public/17.0.0/ucd/extracted/DerivedBidiClass.txt
3
+ /*
4
+ UNICODE LICENSE V3
5
+
6
+ COPYRIGHT AND PERMISSION NOTICE
7
+
8
+ Copyright © 1991-2026 Unicode, Inc.
9
+
10
+ NOTICE TO USER: Carefully read the following legal agreement. BY
11
+ DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
12
+ SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
13
+ TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
14
+ DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
15
+
16
+ Permission is hereby granted, free of charge, to any person obtaining a
17
+ copy of data files and any associated documentation (the "Data Files") or
18
+ software and any associated documentation (the "Software") to deal in the
19
+ Data Files or Software without restriction, including without limitation
20
+ the rights to use, copy, modify, merge, publish, distribute, and/or sell
21
+ copies of the Data Files or Software, and to permit persons to whom the
22
+ Data Files or Software are furnished to do so, provided that either (a)
23
+ this copyright and permission notice appear with all copies of the Data
24
+ Files or Software, or (b) this copyright and permission notice appear in
25
+ associated Documentation.
26
+
27
+ THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
28
+ KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
29
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
30
+ THIRD PARTY RIGHTS.
31
+
32
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
33
+ BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
34
+ OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
35
+ WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
36
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
37
+ FILES OR SOFTWARE.
38
+
39
+ Except as contained in this notice, the name of a copyright holder shall
40
+ not be used in advertising or otherwise to promote the sale, use or other
41
+ dealings in these Data Files or Software without prior written
42
+ authorization of the copyright holder.
43
+ */
44
+ export const strongDirectionPattern = /[\u{41}-\u{5a}\u{61}-\u{7a}\u{aa}\u{b5}\u{ba}\u{c0}-\u{d6}\u{d8}-\u{f6}\u{f8}-\u{2b8}\u{2bb}-\u{2c1}\u{2d0}-\u{2d1}\u{2e0}-\u{2e4}\u{2ee}\u{370}-\u{373}\u{376}-\u{37d}\u{37f}-\u{383}\u{386}\u{388}-\u{3f5}\u{3f7}-\u{482}\u{48a}-\u{589}\u{58b}-\u{58c}\u{590}\u{5be}\u{5c0}\u{5c3}\u{5c6}\u{5c8}-\u{5ff}\u{608}\u{60b}\u{60d}\u{61b}-\u{64a}\u{66d}-\u{66f}\u{671}-\u{6d5}\u{6e5}-\u{6e6}\u{6ee}-\u{6ef}\u{6fa}-\u{710}\u{712}-\u{72f}\u{74b}-\u{7a5}\u{7b1}-\u{7ea}\u{7f4}-\u{7f5}\u{7fa}-\u{7fc}\u{7fe}-\u{815}\u{81a}\u{824}\u{828}\u{82e}-\u{858}\u{85c}-\u{88f}\u{892}-\u{896}\u{8a0}-\u{8c9}\u{903}-\u{939}\u{93b}\u{93d}-\u{940}\u{949}-\u{94c}\u{94e}-\u{950}\u{958}-\u{961}\u{964}-\u{980}\u{982}-\u{9bb}\u{9bd}-\u{9c0}\u{9c5}-\u{9cc}\u{9ce}-\u{9e1}\u{9e4}-\u{9f1}\u{9f4}-\u{9fa}\u{9fc}-\u{9fd}\u{9ff}-\u{a00}\u{a03}-\u{a3b}\u{a3d}-\u{a40}\u{a43}-\u{a46}\u{a49}-\u{a4a}\u{a4e}-\u{a50}\u{a52}-\u{a6f}\u{a72}-\u{a74}\u{a76}-\u{a80}\u{a83}-\u{abb}\u{abd}-\u{ac0}\u{ac6}\u{ac9}-\u{acc}\u{ace}-\u{ae1}\u{ae4}-\u{af0}\u{af2}-\u{af9}\u{b00}\u{b02}-\u{b3b}\u{b3d}-\u{b3e}\u{b40}\u{b45}-\u{b4c}\u{b4e}-\u{b54}\u{b57}-\u{b61}\u{b64}-\u{b81}\u{b83}-\u{bbf}\u{bc1}-\u{bcc}\u{bce}-\u{bf2}\u{bfb}-\u{bff}\u{c01}-\u{c03}\u{c05}-\u{c3b}\u{c3d}\u{c41}-\u{c45}\u{c49}\u{c4e}-\u{c54}\u{c57}-\u{c61}\u{c64}-\u{c77}\u{c7f}-\u{c80}\u{c82}-\u{cbb}\u{cbd}-\u{ccb}\u{cce}-\u{ce1}\u{ce4}-\u{cff}\u{d02}-\u{d3a}\u{d3d}-\u{d40}\u{d45}-\u{d4c}\u{d4e}-\u{d61}\u{d64}-\u{d80}\u{d82}-\u{dc9}\u{dcb}-\u{dd1}\u{dd5}\u{dd7}-\u{e30}\u{e32}-\u{e33}\u{e3b}-\u{e3e}\u{e40}-\u{e46}\u{e4f}-\u{eb0}\u{eb2}-\u{eb3}\u{ebd}-\u{ec7}\u{ecf}-\u{f17}\u{f1a}-\u{f34}\u{f36}\u{f38}\u{f3e}-\u{f70}\u{f7f}\u{f85}\u{f88}-\u{f8c}\u{f98}\u{fbd}-\u{fc5}\u{fc7}-\u{102c}\u{1031}\u{1038}\u{103b}-\u{103c}\u{103f}-\u{1057}\u{105a}-\u{105d}\u{1061}-\u{1070}\u{1075}-\u{1081}\u{1083}-\u{1084}\u{1087}-\u{108c}\u{108e}-\u{109c}\u{109e}-\u{135c}\u{1360}-\u{138f}\u{139a}-\u{13ff}\u{1401}-\u{167f}\u{1681}-\u{169a}\u{169d}-\u{1711}\u{1715}-\u{1731}\u{1734}-\u{1751}\u{1754}-\u{1771}\u{1774}-\u{17b3}\u{17b6}\u{17be}-\u{17c5}\u{17c7}-\u{17c8}\u{17d4}-\u{17da}\u{17dc}\u{17de}-\u{17ef}\u{17fa}-\u{17ff}\u{1810}-\u{1884}\u{1887}-\u{18a8}\u{18aa}-\u{191f}\u{1923}-\u{1926}\u{1929}-\u{1931}\u{1933}-\u{1938}\u{193c}-\u{193f}\u{1941}-\u{1943}\u{1946}-\u{19dd}\u{1a00}-\u{1a16}\u{1a19}-\u{1a1a}\u{1a1c}-\u{1a55}\u{1a57}\u{1a5f}\u{1a61}\u{1a63}-\u{1a64}\u{1a6d}-\u{1a72}\u{1a7d}-\u{1a7e}\u{1a80}-\u{1aaf}\u{1ade}-\u{1adf}\u{1aec}-\u{1aff}\u{1b04}-\u{1b33}\u{1b35}\u{1b3b}\u{1b3d}-\u{1b41}\u{1b43}-\u{1b6a}\u{1b74}-\u{1b7f}\u{1b82}-\u{1ba1}\u{1ba6}-\u{1ba7}\u{1baa}\u{1bae}-\u{1be5}\u{1be7}\u{1bea}-\u{1bec}\u{1bee}\u{1bf2}-\u{1c2b}\u{1c34}-\u{1c35}\u{1c38}-\u{1ccf}\u{1cd3}\u{1ce1}\u{1ce9}-\u{1cec}\u{1cee}-\u{1cf3}\u{1cf5}-\u{1cf7}\u{1cfa}-\u{1dbf}\u{1e00}-\u{1fbc}\u{1fbe}\u{1fc2}-\u{1fcc}\u{1fd0}-\u{1fdc}\u{1fe0}-\u{1fec}\u{1ff0}-\u{1ffc}\u{1fff}\u{200e}-\u{200f}\u{2071}-\u{2073}\u{207f}\u{208f}-\u{209f}\u{20f1}-\u{20ff}\u{2102}\u{2107}\u{210a}-\u{2113}\u{2115}\u{2119}-\u{211d}\u{2124}\u{2126}\u{2128}\u{212a}-\u{212d}\u{212f}-\u{2139}\u{213c}-\u{213f}\u{2145}-\u{2149}\u{214e}-\u{214f}\u{2160}-\u{2188}\u{218c}-\u{218f}\u{2336}-\u{237a}\u{2395}\u{242a}-\u{243f}\u{244b}-\u{245f}\u{249c}-\u{24e9}\u{26ac}\u{2800}-\u{28ff}\u{2b74}-\u{2b75}\u{2c00}-\u{2ce4}\u{2ceb}-\u{2cee}\u{2cf2}-\u{2cf8}\u{2d00}-\u{2d7e}\u{2d80}-\u{2ddf}\u{2e5e}-\u{2e7f}\u{2e9a}\u{2ef4}-\u{2eff}\u{2fd6}-\u{2fef}\u{3005}-\u{3007}\u{3021}-\u{3029}\u{302e}-\u{302f}\u{3031}-\u{3035}\u{3038}-\u{303c}\u{3040}-\u{3098}\u{309d}-\u{309f}\u{30a1}-\u{30fa}\u{30fc}-\u{31bf}\u{31e6}-\u{31ee}\u{31f0}-\u{321c}\u{321f}-\u{324f}\u{3260}-\u{327b}\u{327f}-\u{32b0}\u{32c0}-\u{32cb}\u{32d0}-\u{3376}\u{337b}-\u{33dd}\u{33e0}-\u{33fe}\u{3400}-\u{4dbf}\u{4e00}-\u{a48f}\u{a4c7}-\u{a60c}\u{a610}-\u{a66e}\u{a680}-\u{a69d}\u{a6a0}-\u{a6ef}\u{a6f2}-\u{a6ff}\u{a722}-\u{a787}\u{a789}-\u{a801}\u{a803}-\u{a805}\u{a807}-\u{a80a}\u{a80c}-\u{a824}\u{a827}\u{a82d}-\u{a837}\u{a83a}-\u{a873}\u{a878}-\u{a8c3}\u{a8c6}-\u{a8df}\u{a8f2}-\u{a8fe}\u{a900}-\u{a925}\u{a92e}-\u{a946}\u{a952}-\u{a97f}\u{a983}-\u{a9b2}\u{a9b4}-\u{a9b5}\u{a9ba}-\u{a9bb}\u{a9be}-\u{a9e4}\u{a9e6}-\u{aa28}\u{aa2f}-\u{aa30}\u{aa33}-\u{aa34}\u{aa37}-\u{aa42}\u{aa44}-\u{aa4b}\u{aa4d}-\u{aa7b}\u{aa7d}-\u{aaaf}\u{aab1}\u{aab5}-\u{aab6}\u{aab9}-\u{aabd}\u{aac0}\u{aac2}-\u{aaeb}\u{aaee}-\u{aaf5}\u{aaf7}-\u{ab69}\u{ab6c}-\u{abe4}\u{abe6}-\u{abe7}\u{abe9}-\u{abec}\u{abee}-\u{fb1d}\u{fb1f}-\u{fb28}\u{fb2a}-\u{fbc2}\u{fbd3}-\u{fd3d}\u{fd50}-\u{fd8f}\u{fd92}-\u{fdc7}\u{fdf0}-\u{fdfc}\u{fe1a}-\u{fe1f}\u{fe53}\u{fe67}\u{fe6c}-\u{fefe}\u{ff00}\u{ff21}-\u{ff3a}\u{ff41}-\u{ff5a}\u{ff66}-\u{ffdf}\u{ffe7}\u{ffef}\u{10000}-\u{10100}\u{10102}-\u{1013f}\u{1018d}-\u{1018f}\u{1019d}-\u{1019f}\u{101a1}-\u{101fc}\u{101fe}-\u{102df}\u{102fc}-\u{10375}\u{1037b}-\u{1091e}\u{10920}-\u{10a00}\u{10a04}\u{10a07}-\u{10a0b}\u{10a10}-\u{10a37}\u{10a3b}-\u{10a3e}\u{10a40}-\u{10ae4}\u{10ae7}-\u{10b38}\u{10b40}-\u{10d23}\u{10d28}-\u{10d2f}\u{10d3a}-\u{10d3f}\u{10d4a}-\u{10d68}\u{10d6f}-\u{10e5f}\u{10e7f}-\u{10eaa}\u{10ead}-\u{10ecf}\u{10ed9}-\u{10ef9}\u{10f00}-\u{10f45}\u{10f51}-\u{10f81}\u{10f86}-\u{11000}\u{11002}-\u{11037}\u{11047}-\u{11051}\u{11066}-\u{1106f}\u{11071}-\u{11072}\u{11075}-\u{1107e}\u{11082}-\u{110b2}\u{110b7}-\u{110b8}\u{110bb}-\u{110c1}\u{110c3}-\u{110ff}\u{11103}-\u{11126}\u{1112c}\u{11135}-\u{11172}\u{11174}-\u{1117f}\u{11182}-\u{111b5}\u{111bf}-\u{111c8}\u{111cd}-\u{111ce}\u{111d0}-\u{1122e}\u{11232}-\u{11233}\u{11235}\u{11238}-\u{1123d}\u{1123f}-\u{11240}\u{11242}-\u{112de}\u{112e0}-\u{112e2}\u{112eb}-\u{112ff}\u{11302}-\u{1133a}\u{1133d}-\u{1133f}\u{11341}-\u{11365}\u{1136d}-\u{1136f}\u{11375}-\u{113ba}\u{113c1}-\u{113cd}\u{113cf}\u{113d1}\u{113d3}-\u{113e0}\u{113e3}-\u{11437}\u{11440}-\u{11441}\u{11445}\u{11447}-\u{1145d}\u{1145f}-\u{114b2}\u{114b9}\u{114bb}-\u{114be}\u{114c1}\u{114c4}-\u{115b1}\u{115b6}-\u{115bb}\u{115be}\u{115c1}-\u{115db}\u{115de}-\u{11632}\u{1163b}-\u{1163c}\u{1163e}\u{11641}-\u{1165f}\u{1166d}-\u{116aa}\u{116ac}\u{116ae}-\u{116af}\u{116b6}\u{116b8}-\u{1171c}\u{1171e}\u{11720}-\u{11721}\u{11726}\u{1172c}-\u{1182e}\u{11838}\u{1183b}-\u{1193a}\u{1193d}\u{1193f}-\u{11942}\u{11944}-\u{119d3}\u{119d8}-\u{119d9}\u{119dc}-\u{119df}\u{119e1}-\u{11a00}\u{11a07}-\u{11a08}\u{11a0b}-\u{11a32}\u{11a39}-\u{11a3a}\u{11a3f}-\u{11a46}\u{11a48}-\u{11a50}\u{11a57}-\u{11a58}\u{11a5c}-\u{11a89}\u{11a97}\u{11a9a}-\u{11b5f}\u{11b61}\u{11b65}\u{11b67}-\u{11c2f}\u{11c37}\u{11c3e}-\u{11c91}\u{11ca8}-\u{11ca9}\u{11cb1}\u{11cb4}\u{11cb7}-\u{11d30}\u{11d37}-\u{11d39}\u{11d3b}\u{11d3e}\u{11d46}\u{11d48}-\u{11d8f}\u{11d92}-\u{11d94}\u{11d96}\u{11d98}-\u{11ef2}\u{11ef5}-\u{11eff}\u{11f02}-\u{11f35}\u{11f3b}-\u{11f3f}\u{11f41}\u{11f43}-\u{11f59}\u{11f5b}-\u{11fd4}\u{11ff2}-\u{1343f}\u{13441}-\u{13446}\u{13456}-\u{1611d}\u{1612a}-\u{1612c}\u{16130}-\u{16aef}\u{16af5}-\u{16b2f}\u{16b37}-\u{16f4e}\u{16f50}-\u{16f8e}\u{16f93}-\u{16fe1}\u{16fe3}\u{16fe5}-\u{1bc9c}\u{1bc9f}\u{1bca4}-\u{1cbff}\u{1ccd6}-\u{1ccef}\u{1ccfd}-\u{1ccff}\u{1ceb4}-\u{1ceb9}\u{1ced1}-\u{1cedf}\u{1cef1}-\u{1ceff}\u{1cf2e}-\u{1cf2f}\u{1cf47}-\u{1d166}\u{1d16a}-\u{1d172}\u{1d183}-\u{1d184}\u{1d18c}-\u{1d1a9}\u{1d1ae}-\u{1d1e8}\u{1d1eb}-\u{1d1ff}\u{1d246}-\u{1d2ff}\u{1d357}-\u{1d6c0}\u{1d6c2}-\u{1d6da}\u{1d6dc}-\u{1d6fa}\u{1d6fc}-\u{1d714}\u{1d716}-\u{1d734}\u{1d736}-\u{1d74e}\u{1d750}-\u{1d76e}\u{1d770}-\u{1d788}\u{1d78a}-\u{1d7a8}\u{1d7aa}-\u{1d7c2}\u{1d7c4}-\u{1d7cd}\u{1d800}-\u{1d9ff}\u{1da37}-\u{1da3a}\u{1da6d}-\u{1da74}\u{1da76}-\u{1da83}\u{1da85}-\u{1da9a}\u{1daa0}\u{1dab0}-\u{1dfff}\u{1e007}\u{1e019}-\u{1e01a}\u{1e022}\u{1e025}\u{1e02b}-\u{1e08e}\u{1e090}-\u{1e12f}\u{1e137}-\u{1e2ad}\u{1e2af}-\u{1e2eb}\u{1e2f0}-\u{1e2fe}\u{1e300}-\u{1e4eb}\u{1e4f0}-\u{1e5ed}\u{1e5f0}-\u{1e6e2}\u{1e6e4}-\u{1e6e5}\u{1e6e7}-\u{1e6ed}\u{1e6f0}-\u{1e6f4}\u{1e6f6}-\u{1e8cf}\u{1e8d7}-\u{1e943}\u{1e94b}-\u{1eeef}\u{1eef2}-\u{1efff}\u{1f02c}-\u{1f02f}\u{1f094}-\u{1f09f}\u{1f0af}-\u{1f0b0}\u{1f0c0}\u{1f0d0}\u{1f0f6}-\u{1f0ff}\u{1f110}-\u{1f12e}\u{1f130}-\u{1f169}\u{1f170}-\u{1f1ac}\u{1f1ae}-\u{1f25f}\u{1f266}-\u{1f2ff}\u{1f6d9}-\u{1f6db}\u{1f6ed}-\u{1f6ef}\u{1f6fd}-\u{1f6ff}\u{1f7da}-\u{1f7df}\u{1f7ec}-\u{1f7ef}\u{1f7f1}-\u{1f7ff}\u{1f80c}-\u{1f80f}\u{1f848}-\u{1f84f}\u{1f85a}-\u{1f85f}\u{1f888}-\u{1f88f}\u{1f8ae}-\u{1f8af}\u{1f8bc}-\u{1f8bf}\u{1f8c2}-\u{1f8cf}\u{1f8d9}-\u{1f8ff}\u{1fa58}-\u{1fa5f}\u{1fa6e}-\u{1fa6f}\u{1fa7d}-\u{1fa7f}\u{1fa8b}-\u{1fa8d}\u{1fac7}\u{1fac9}-\u{1facc}\u{1fadd}-\u{1fade}\u{1faeb}-\u{1faee}\u{1faf9}-\u{1faff}\u{1fb93}\u{1fbfb}-\u{1fffd}\u{20000}-\u{2fffd}\u{30000}-\u{3fffd}\u{40000}-\u{4fffd}\u{50000}-\u{5fffd}\u{60000}-\u{6fffd}\u{70000}-\u{7fffd}\u{80000}-\u{8fffd}\u{90000}-\u{9fffd}\u{a0000}-\u{afffd}\u{b0000}-\u{bfffd}\u{c0000}-\u{cfffd}\u{d0000}-\u{dfffd}\u{e1000}-\u{efffd}\u{f0000}-\u{ffffd}\u{100000}-\u{10fffd}]/u;
45
+ export const rightToLeftPattern = /[\u{590}\u{5be}\u{5c0}\u{5c3}\u{5c6}\u{5c8}-\u{5ff}\u{608}\u{60b}\u{60d}\u{61b}-\u{64a}\u{66d}-\u{66f}\u{671}-\u{6d5}\u{6e5}-\u{6e6}\u{6ee}-\u{6ef}\u{6fa}-\u{710}\u{712}-\u{72f}\u{74b}-\u{7a5}\u{7b1}-\u{7ea}\u{7f4}-\u{7f5}\u{7fa}-\u{7fc}\u{7fe}-\u{815}\u{81a}\u{824}\u{828}\u{82e}-\u{858}\u{85c}-\u{88f}\u{892}-\u{896}\u{8a0}-\u{8c9}\u{200f}\u{fb1d}\u{fb1f}-\u{fb28}\u{fb2a}-\u{fbc2}\u{fbd3}-\u{fd3d}\u{fd50}-\u{fd8f}\u{fd92}-\u{fdc7}\u{fdf0}-\u{fdfc}\u{fe70}-\u{fefe}\u{10800}-\u{1091e}\u{10920}-\u{10a00}\u{10a04}\u{10a07}-\u{10a0b}\u{10a10}-\u{10a37}\u{10a3b}-\u{10a3e}\u{10a40}-\u{10ae4}\u{10ae7}-\u{10b38}\u{10b40}-\u{10d23}\u{10d28}-\u{10d2f}\u{10d3a}-\u{10d3f}\u{10d4a}-\u{10d68}\u{10d6f}-\u{10e5f}\u{10e7f}-\u{10eaa}\u{10ead}-\u{10ecf}\u{10ed9}-\u{10ef9}\u{10f00}-\u{10f45}\u{10f51}-\u{10f81}\u{10f86}-\u{10fff}\u{1e800}-\u{1e8cf}\u{1e8d7}-\u{1e943}\u{1e94b}-\u{1eeef}\u{1eef2}-\u{1efff}]/u;
@@ -1,20 +1,24 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useMemo, useState } from 'react';
3
3
  import { Text } from 'ink';
4
- import { displayWidth, sliceDisplay, truncate } from '../format.js';
4
+ import { displayWidth, sliceDisplay, sliceDisplayFromEnd, truncate } from '../format.js';
5
5
  import { useDisplay } from '../display-context.js';
6
6
  import { toAsciiSafe } from '../ascii.js';
7
+ import { textDirection } from '../text-direction.js';
7
8
  const edgePauseMs = 1000;
8
9
  const stepMs = 180;
9
10
  export function AdaptiveMarquee({ text, width, active, reduceMotion }) {
10
11
  const { ascii, reduceMotion: terminalReduceMotion } = useDisplay();
11
- const safeWidth = Math.max(0, width);
12
+ const safeWidth = Math.max(0, Math.floor(width));
12
13
  const maxOffset = Math.max(0, displayWidth(text) - safeWidth);
13
- const animated = active && !reduceMotion && !terminalReduceMotion && maxOffset > 0;
14
+ const animated = active && !reduceMotion && !terminalReduceMotion && safeWidth > 0 && maxOffset > 0;
15
+ // Reset position and cancel the old timer before rendering a changed name or
16
+ // viewport. An effect-based reset can emit a frame using the previous offset.
17
+ return _jsx(MarqueeText, { text: text, width: safeWidth, maxOffset: maxOffset, animated: animated, ascii: ascii }, JSON.stringify([text, safeWidth, animated]));
18
+ }
19
+ function MarqueeText({ text, width, maxOffset, animated, ascii }) {
20
+ const direction = useMemo(() => textDirection(text), [text]);
14
21
  const [offset, setOffset] = useState(0);
15
- useEffect(() => {
16
- setOffset(0);
17
- }, [active, reduceMotion, terminalReduceMotion, safeWidth, text]);
18
22
  useEffect(() => {
19
23
  if (!animated)
20
24
  return;
@@ -24,8 +28,11 @@ export function AdaptiveMarquee({ text, width, active, reduceMotion }) {
24
28
  }, delay);
25
29
  return () => clearTimeout(timer);
26
30
  }, [animated, maxOffset, offset]);
31
+ const progress = Math.min(offset, maxOffset);
32
+ // Move the window toward the beginning for RTL text, keeping the original
33
+ // character order. Glyph shaping and bidi rendering remain the terminal's job.
27
34
  const visible = animated
28
- ? sliceDisplay(text, Math.min(offset, maxOffset), safeWidth)
29
- : truncate(text, safeWidth);
35
+ ? (direction === 'rtl' ? sliceDisplayFromEnd : sliceDisplay)(text, progress, width)
36
+ : truncate(text, width);
30
37
  return _jsx(Text, { "aria-label": text, children: ascii ? toAsciiSafe(visible) : visible });
31
38
  }
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Transform } from 'ink';
3
+ import { Chalk } from 'chalk';
4
+ import { toAsciiSafe } from '../ascii.js';
5
+ import { useDisplay } from '../display-context.js';
6
+ import { displayWidth, sliceDisplay } from '../format.js';
7
+ const colors = [0, 1, 2, 3].map(level => new Chalk({ level: level }));
8
+ /** Keep animated text out of Ink 7's process-lifetime measurement/wrap caches.
9
+ * Transform must be a direct child of a Box, never nested inside Text: nested
10
+ * transforms are flattened before measurement. Only the fixed-width blank is
11
+ * measured; the current frame is styled at output time and then discarded.
12
+ */
13
+ export function ReceiverVisualRow({ row, width, ascii, backgroundColor }) {
14
+ const { colorLevel } = useDisplay();
15
+ const chalk = colors[colorLevel];
16
+ const segments = row.segments ?? [{ text: row.text, color: row.color }];
17
+ const placeholder = ' '.repeat(Math.max(0, width));
18
+ return _jsx(Transform, { transform: visiblePlaceholder => {
19
+ // Ink clips before invoking Transform. Respect the remaining width so a
20
+ // narrow terminal cannot receive a full row after clipping the placeholder.
21
+ let remaining = displayWidth(visiblePlaceholder);
22
+ let output = '';
23
+ for (const segment of segments) {
24
+ const source = ascii ? toAsciiSafe(segment.text) : segment.text;
25
+ const text = sliceDisplay(source, 0, remaining);
26
+ remaining -= displayWidth(text);
27
+ let styled = chalk.hex(segment.color)(text);
28
+ const background = segment.backgroundColor ?? backgroundColor;
29
+ if (background)
30
+ styled = chalk.bgHex(background)(styled);
31
+ if (segment.bold)
32
+ styled = chalk.bold(styled);
33
+ output += styled;
34
+ if (remaining <= 0 || text !== source)
35
+ break;
36
+ }
37
+ return output;
38
+ }, children: placeholder });
39
+ }
package/dist/ui/format.js CHANGED
@@ -1,3 +1,7 @@
1
+ // Use Ink's width rules on supported Node runtimes, including emoji sequences
2
+ // and text/emoji presentation selectors. Retain code-point fallback for hosts
3
+ // without Intl.Segmenter, where full grapheme rendering is unavailable.
4
+ const rendererWidth = typeof Intl.Segmenter === 'function' ? (await import('string-width')).default : undefined;
1
5
  export function stationLocation(station) {
2
6
  return [station.city, station.state, station.country].filter(Boolean).join(', ') || station.countryCode || 'Unknown origin';
3
7
  }
@@ -35,6 +39,8 @@ export function truncate(value, length) {
35
39
  return `${result}…`;
36
40
  }
37
41
  export function displayWidth(value) {
42
+ if (rendererWidth)
43
+ return rendererWidth(value);
38
44
  let width = 0;
39
45
  for (const grapheme of graphemes(value)) {
40
46
  if (/^\p{Mark}+$/u.test(grapheme))
@@ -54,12 +60,14 @@ export function sliceDisplay(value, start, length) {
54
60
  let skippedWidth = 0;
55
61
  let outputWidth = 0;
56
62
  let result = '';
63
+ let started = false;
57
64
  for (const grapheme of graphemes(value)) {
58
65
  const graphemeWidth = displayWidth(grapheme);
59
- if (skippedWidth + graphemeWidth <= safeStart) {
66
+ if (!started && skippedWidth + graphemeWidth <= safeStart) {
60
67
  skippedWidth += graphemeWidth;
61
68
  continue;
62
69
  }
70
+ started = true;
63
71
  if (outputWidth + graphemeWidth > safeLength)
64
72
  break;
65
73
  result += grapheme;
@@ -67,15 +75,40 @@ export function sliceDisplay(value, start, length) {
67
75
  }
68
76
  return result;
69
77
  }
78
+ /** Clip from the logical end for rightward scrolling, without reversing text. */
79
+ export function sliceDisplayFromEnd(value, start, length) {
80
+ const safeStart = Math.max(0, Math.floor(start));
81
+ const safeLength = Math.max(0, Math.floor(length));
82
+ if (safeLength === 0)
83
+ return '';
84
+ let skippedWidth = 0;
85
+ let outputWidth = 0;
86
+ const parts = graphemes(value);
87
+ const result = [];
88
+ for (let index = parts.length - 1; index >= 0; index--) {
89
+ const grapheme = parts[index];
90
+ const graphemeWidth = displayWidth(grapheme);
91
+ // Skip the entire cluster when the moving edge lands inside a wide glyph.
92
+ if (skippedWidth < safeStart) {
93
+ skippedWidth += graphemeWidth;
94
+ continue;
95
+ }
96
+ if (outputWidth + graphemeWidth > safeLength)
97
+ break;
98
+ result.push(grapheme);
99
+ outputWidth += graphemeWidth;
100
+ }
101
+ return result.reverse().join('');
102
+ }
70
103
  export function removeLastGrapheme(value) {
71
104
  const parts = graphemes(value);
72
105
  parts.pop();
73
106
  return parts.join('');
74
107
  }
108
+ const graphemeSegmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
75
109
  function graphemes(value) {
76
- const Segmenter = Intl.Segmenter;
77
- if (Segmenter) {
78
- return [...new Segmenter(undefined, { granularity: 'grapheme' }).segment(value)].map(item => item.segment);
110
+ if (graphemeSegmenter) {
111
+ return [...graphemeSegmenter.segment(value)].map(item => item.segment);
79
112
  }
80
113
  return Array.from(value);
81
114
  }
@@ -70,7 +70,7 @@ export const commandHelp = [
70
70
  { name: 'ascii', args: '<on|off>', description: 'ASCII-safe display' },
71
71
  { name: 'motion', args: '<on|off>', description: 'Reduce motion' },
72
72
  { name: 'background', args: '<on|off>', description: 'Transparent background' },
73
- { name: 'airplay', description: 'Open AirPlay settings (macOS only)' },
73
+ { name: 'airplay', description: 'Open experimental AirPlay settings' },
74
74
  { name: 'learn', args: '<previous|play|next>', description: 'Learn a media key' },
75
75
  { name: 'keys', args: '[reset]', description: 'Show or reset learned media keys' },
76
76
  { name: 'favorite', description: 'Favorite the current station (alias :fav)' },
@@ -85,7 +85,7 @@ export const commandHelp = [
85
85
  { name: 'stop', description: 'Stop playback' },
86
86
  { name: 'help', description: 'Open this help' }
87
87
  ];
88
- export const helpItemCount = keyHelpSections.reduce((total, section) => total + 1 + section.entries.length, 0) +
88
+ const helpItemCount = keyHelpSections.reduce((total, section) => total + 1 + section.entries.length, 0) +
89
89
  1 +
90
90
  commandHelp.length;
91
91
  // Primary command names plus the aliases the executor accepts, used for
@@ -134,3 +134,6 @@ function longestCommonPrefix(values) {
134
134
  }
135
135
  return prefix;
136
136
  }
137
+ export function commandHelpForAirPlay(visible = true) { return commandHelp.filter(command => visible || !command.name.startsWith('airplay')); }
138
+ export function commandNamesForAirPlay(visible = true) { return commandNames.filter(name => visible || !name.startsWith('airplay')); }
139
+ export function helpItemCountForAirPlay(visible = true) { return helpItemCount - commandHelp.length + commandHelpForAirPlay(visible).length; }
@@ -0,0 +1,137 @@
1
+ import { stripVTControlCharacters } from 'node:util';
2
+ import { rightToLeftPattern, strongDirectionPattern } from './bidi-ranges.js';
3
+ import { displayWidth } from './format.js';
4
+ // Ink writes complete VT sequences. Keep OSC payloads (titles, hyperlinks) out
5
+ // of text analysis, and treat cursor controls as boundaries between text runs.
6
+ const controls = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)|[78@-_])|[\u0000-\u001F\u007F]/gu;
7
+ const sgr = /^\u001B\[[\d;:]*m$/u;
8
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
9
+ /**
10
+ * Yoga's boxes become a single terminal row, not independently positioned
11
+ * fields. Guard fixed fields and borders against terminal-side shaping in
12
+ * mixed RTL rows by explicitly positioning the columns around each RTL run.
13
+ * Apple Terminal's xterm profile also gets Unicode isolates to contain stored
14
+ * row reordering independently of cursor reports. Keep those opt-in: older
15
+ * Linux/BSD consoles may print unsupported format marks.
16
+ * Ordinary LTR output stays byte-for-byte unchanged.
17
+ *
18
+ * Apply only to Ink's interactive stdout, after its cell layout/tokenization:
19
+ * inserting zero-width bidi marks into Text would itself consume Ink cells.
20
+ */
21
+ export function rtlSafeOutput(stdout, env = process.env) {
22
+ const isolate = env.TERM_PROGRAM === 'Apple_Terminal' && /^xterm(?:-|$)/i.test(env.TERM ?? '');
23
+ let column = 0;
24
+ let savedColumn;
25
+ const position = (control) => {
26
+ if (control === '\n' || control === '\r')
27
+ column = 0;
28
+ else if (control === '\b' && column !== undefined)
29
+ column = Math.max(0, column - 1);
30
+ else if (control === '\t' && column !== undefined)
31
+ column = (Math.floor(column / 8) + 1) * 8;
32
+ else if (control === '\u001B7' || control === '\u001B[s')
33
+ savedColumn = column;
34
+ else if (control === '\u001B8' || control === '\u001B[u')
35
+ column = savedColumn;
36
+ else {
37
+ const csi = /^\u001B\[([\d;]*)([G`CDaHEFf])$/u.exec(control);
38
+ if (!csi)
39
+ return;
40
+ const parameters = csi[1].split(';').map(value => Number(value) || 1);
41
+ const command = csi[2];
42
+ if (command === 'G' || command === '`')
43
+ column = parameters[0] - 1;
44
+ else if (command === 'H' || command === 'f')
45
+ column = (parameters[1] ?? 1) - 1;
46
+ else if (command === 'E' || command === 'F')
47
+ column = 0;
48
+ else if (column !== undefined)
49
+ column = Math.max(0, column + (command === 'D' ? -1 : 1) * parameters[0]);
50
+ }
51
+ };
52
+ const text = (span, endsRow = false) => {
53
+ const plain = stripVTControlCharacters(span);
54
+ const hasRtl = rightToLeftPattern.test(plain);
55
+ // Animation frames contain many art-only rows. Their next newline resets
56
+ // the column, so do not remeasure thousands of unchanged visualizer cells.
57
+ if (!hasRtl && endsRow)
58
+ return span;
59
+ const startColumn = column;
60
+ if (column !== undefined)
61
+ column += displayWidth(plain);
62
+ if (startColumn === undefined || !hasRtl)
63
+ return span;
64
+ const runs = [];
65
+ let first;
66
+ let last;
67
+ let offset = startColumn;
68
+ const finish = () => {
69
+ if (first && last)
70
+ runs.push({ first, last });
71
+ first = undefined;
72
+ last = undefined;
73
+ };
74
+ // Segment the complete visible text: SGR can occur inside a combining or
75
+ // emoji cluster. Map its UTF-16 edges back to the original styled span.
76
+ const indices = [];
77
+ let from = 0;
78
+ for (const match of span.matchAll(new RegExp(controls))) {
79
+ for (let index = from; index < match.index; index++)
80
+ indices.push(index);
81
+ from = match.index + match[0].length;
82
+ }
83
+ for (let index = from; index < span.length; index++)
84
+ indices.push(index);
85
+ // Neutral characters between RTL words belong to the same run, preserving
86
+ // Arabic word order without absorbing trailing padding or fixed fields.
87
+ for (const part of segmenter.segment(plain)) {
88
+ const width = displayWidth(part.segment);
89
+ const strong = strongDirectionPattern.exec(part.segment)?.[0];
90
+ if (strong && rightToLeftPattern.test(strong)) {
91
+ first ??= { index: indices[part.index], column: offset };
92
+ last = { index: indices[part.index + part.segment.length - 1] + 1, column: offset + width };
93
+ }
94
+ else if (strong)
95
+ finish();
96
+ offset += width;
97
+ }
98
+ finish();
99
+ const at = (value) => `\u001B[${value + 1}G`;
100
+ let result = '';
101
+ from = 0;
102
+ for (const run of runs) {
103
+ result += span.slice(from, run.first.index) + at(run.first.column)
104
+ + (isolate ? '\u2067' : '') + span.slice(run.first.index, run.last.index)
105
+ + (isolate ? '\u2069' : '') + at(run.last.column);
106
+ from = run.last.index;
107
+ }
108
+ return result + span.slice(from);
109
+ };
110
+ const transform = (value) => {
111
+ let result = '';
112
+ let from = 0;
113
+ for (const match of value.matchAll(new RegExp(controls))) {
114
+ if (sgr.test(match[0]))
115
+ continue;
116
+ result += text(value.slice(from, match.index), match[0] === '\n' || match[0] === '\r') + match[0];
117
+ position(match[0]);
118
+ from = match.index + match[0].length;
119
+ }
120
+ return result + text(value.slice(from));
121
+ };
122
+ const write = (chunk, ...args) => {
123
+ // Ink frames are strings. Preserve other writes and their encoding/callback
124
+ // contract; resume anchoring only after a known cursor position or newline.
125
+ if (typeof chunk !== 'string')
126
+ column = undefined;
127
+ return Reflect.apply(stdout.write, stdout, [typeof chunk === 'string' ? transform(chunk) : chunk, ...args]);
128
+ };
129
+ return new Proxy(stdout, {
130
+ get(target, key) {
131
+ if (key === 'write')
132
+ return write;
133
+ const value = Reflect.get(target, key, target);
134
+ return typeof value === 'function' ? value.bind(target) : value;
135
+ }
136
+ });
137
+ }
@@ -91,11 +91,17 @@ export const settingsGroups = [
91
91
  ];
92
92
  export const settingsItems = settingsGroups.flatMap(group => group.items);
93
93
  export const settingsRootItems = settingsGroups.map(group => group.label);
94
- export function settingsGroup(page) {
95
- return settingsGroups.find(group => group.id === page);
94
+ export function settingsGroupsForAirPlay(visible = true) {
95
+ return settingsGroups.map(group => ({ ...group,
96
+ description: !visible && group.id === 'playback' ? 'Output, volume, and startup behavior' : group.description,
97
+ items: group.items.filter(item => visible || item !== 'AirPlay receiver')
98
+ }));
96
99
  }
97
- export function settingsItemsForPage(page) {
98
- return page === 'root' ? settingsRootItems : settingsGroup(page)?.items ?? [];
100
+ export function settingsGroup(page, airPlayVisible = true) {
101
+ return settingsGroupsForAirPlay(airPlayVisible).find(group => group.id === page);
102
+ }
103
+ export function settingsItemsForPage(page, airPlayVisible = true) {
104
+ return page === 'root' ? settingsRootItems : settingsGroup(page, airPlayVisible)?.items ?? [];
99
105
  }
100
106
  export function settingsPageForRootItem(item) {
101
107
  return settingsGroups.find(group => group.label === item)?.id;
@@ -9,11 +9,11 @@ import { textDim, textMuted, themeAccent } from '../theme.js';
9
9
  import { visibleWindow } from '../list-window.js';
10
10
  import { useDisplay } from '../display-context.js';
11
11
  import { toAsciiSafe } from '../ascii.js';
12
- export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width, height }) {
12
+ export function AirPlaySettingsScreen({ platform = process.platform, selected, settings, backends, devices, theme, width, height }) {
13
13
  const { ascii } = useDisplay();
14
14
  const a = (value) => ascii ? toAsciiSafe(value) : value;
15
15
  const accent = themeAccent(theme);
16
- const availability = airPlayAvailability(backends, devices);
16
+ const availability = airPlayAvailability(backends, devices, platform);
17
17
  const selectedDevice = selectedAirPlayDevice(settings, devices);
18
18
  const selectedMissing = Boolean(settings.preferredAirPlayDevice && !selectedDevice);
19
19
  const canEnterCode = availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local;
@@ -24,7 +24,7 @@ export function AirPlaySettingsScreen({ selected, settings, backends, devices, t
24
24
  const isSelected = device.id === settings.preferredAirPlayDevice;
25
25
  const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
26
26
  const selectedLabel = isSelected ? ' selected' : '';
27
- const detail = truncate(airPlayDeviceDetail(device), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
27
+ const detail = truncate(airPlayDeviceDetail(device, platform), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
28
28
  return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(name) }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsx(Text, { color: textMuted, children: a(` · ${detail}`) })] }));
29
29
  } })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: a(canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers') })] })] }));
30
30
  }
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
2
3
  import { Box, Text } from 'ink';
3
4
  import { draftNextOccurrence } from '../alarm-editor.js';
4
- import { nextOccurrenceForAlarm } from '../../alarms/schedule.js';
5
+ import { AlarmOccurrenceCache } from '../../alarms/occurrence-cache.js';
5
6
  import { stationKey } from '../../storage/store.js';
6
7
  import { visibleWindow } from '../list-window.js';
7
8
  import { ScreenHeader } from '../components/ScreenHeader.js';
@@ -15,10 +16,12 @@ export function AlarmsScreen({ alarms, selected, runtime, verification, deleting
15
16
  const { ascii, reduceMotion } = useDisplay();
16
17
  const a = (value) => ascii ? toAsciiSafe(value) : value;
17
18
  const mode = requestedMode ?? (width < 34 ? 'micro' : width < 68 ? 'compact' : 'full');
19
+ const occurrenceCache = useMemo(() => new AlarmOccurrenceCache(), []);
18
20
  if (verification)
19
21
  return _jsx(AlarmVerificationScreen, { report: verification, theme: theme, width: width, height: height });
20
22
  const status = schedulerStatus(runtime, mode);
21
- const next = alarms.map(alarm => ({ alarm, occurrence: nextOccurrenceForAlarm(alarm, new Date()) })).filter(item => item.occurrence).sort((a, b) => a.occurrence.getTime() - b.occurrence.getTime())[0];
23
+ const now = new Date();
24
+ const next = alarms.map(alarm => ({ alarm, occurrence: occurrenceCache.next(alarm, now) })).filter(item => item.occurrence).sort((a, b) => a.occurrence.getTime() - b.occurrence.getTime())[0];
22
25
  const noticeRows = height >= 11 ? 2 : 1;
23
26
  const rows = Math.max(1, height - 5 - noticeRows);
24
27
  const items = [{ kind: 'create' }, ...alarms.map(alarm => ({ kind: 'alarm', alarm })), { kind: 'verify' }];
@@ -2,12 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { textMuted, themeAccent } from '../theme.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
- import { commandHelp, keyHelpSections } from '../help-content.js';
5
+ import { commandHelpForAirPlay, keyHelpSections } from '../help-content.js';
6
6
  import { padDisplayEnd, truncate } from '../format.js';
7
7
  import { visibleWindow } from '../list-window.js';
8
8
  import { useDisplay } from '../display-context.js';
9
9
  import { toAsciiSafe } from '../ascii.js';
10
- export function HelpScreen({ theme, width, height, selected }) {
10
+ export function HelpScreen({ theme, width, height, selected, airPlaySupported = true }) {
11
11
  const { ascii } = useDisplay();
12
12
  const a = (value) => ascii ? toAsciiSafe(value) : value;
13
13
  const accent = themeAccent(theme);
@@ -19,7 +19,7 @@ export function HelpScreen({ theme, width, height, selected }) {
19
19
  ...section.entries.map(entry => ({ key: `${section.title}-${entry.keys}`, ...entry }))
20
20
  ]),
21
21
  { key: 'commands', heading: 'Commands (press : then type)' },
22
- ...commandHelp.map(command => ({
22
+ ...commandHelpForAirPlay(airPlaySupported).map(command => ({
23
23
  key: `command-${command.name}`,
24
24
  keys: `:${command.name}${command.args ? ` ${command.args}` : ''}`,
25
25
  description: command.description