authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-12 12:03:35+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-12 12:03:35+02:00
logc1ec1915fc16f3dc1ec5d47d9a97f6210a574526
treed821340c852d7c8372e208ccc540b7aa530af038
parent619b0ece940ed0ae92a1efffc2b3a1409f0c95d8
signature Signed by PGP key B5690EEEBB952194

[codex] Add Android mobile support (#3579)

Co-authored-by: Horus Lugo <horusgoul@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: maria-rcks <maria@kuuro.net> Co-authored-by: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Co-authored-by: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Co-authored-by: Alex <me@pixp.cc> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <julius@mac.lan>

166 files changed, 17354 insertions(+), 686 deletions(-)

app.json created+3
...@@ -0,0 +1,3 @@
1{
2 "expo": {}
3}
apps/mobile/README.md+24
...@@ -34,6 +34,30 @@ Build and run the local iOS dev client:...@@ -34,6 +34,30 @@ Build and run the local iOS dev client:
34vp run ios:dev34vp run ios:dev
35```35```
3636
37If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the
38reduced-capability local build. Personal Team builds omit the widget extension, push entitlement, and
39native Sign in with Apple entitlement; builds without this opt-in are unchanged.
40
41```bash
42T3CODE_IOS_PERSONAL_TEAM=1 \
43T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=com.example.t3code.dev \
44vp run ios:dev
45```
46
47Build and install a self-contained Release app that does not need Metro:
48
49```bash
50vp run ios:release
51```
52
53The Personal Team equivalent also needs a unique bundle identifier:
54
55```bash
56T3CODE_IOS_PERSONAL_TEAM=1 \
57T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=com.example.t3code \
58vp run ios:release
59```
60
37Build and run the local iOS preview app:61Build and run the local iOS preview app:
3862
39```bash63```bash
apps/mobile/app.config.ts+62-22
...@@ -8,6 +8,20 @@ const repoEnv = loadRepoEnv();...@@ -8,6 +8,20 @@ const repoEnv = loadRepoEnv();
8Object.assign(process.env, repoEnv);8Object.assign(process.env, repoEnv);
99
10const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT);10const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT);
11const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1";
12
13const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim();
14const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
15
16if (
17 isIosPersonalTeamBuild &&
18 (!personalTeamBundleIdentifier ||
19 !IOS_BUNDLE_IDENTIFIER_PATTERN.test(personalTeamBundleIdentifier))
20) {
21 throw new Error(
22 "T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID must be a reverse-DNS identifier such as com.example.t3code when T3CODE_IOS_PERSONAL_TEAM=1.",
23 );
24}
1125
12const VARIANT_CONFIG: Record<26const VARIANT_CONFIG: Record<
13 AppVariant,27 AppVariant,
...@@ -69,6 +83,26 @@ const dmSansFonts = {...@@ -69,6 +83,26 @@ const dmSansFonts = {
69 bold: "@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",83 bold: "@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",
70} as const;84} as const;
7185
86const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
87 "expo-widgets",
88 {
89 bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,
90 groupIdentifier: `group.${variant.iosBundleIdentifier}`,
91 enablePushNotifications: true,
92 // Agent activity can update many times an hour; without the
93 // frequent-updates entitlement iOS throttles the update budget sooner.
94 frequentUpdates: true,
95 widgets: [
96 {
97 name: "AgentActivity",
98 displayName: "Agent Activity",
99 description: "Shows the current state of active T3 Code agents.",
100 supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"],
101 },
102 ],
103 },
104];
105
72// These aliases match the fonts' PostScript names on iOS. Register the same106// These aliases match the fonts' PostScript names on iOS. Register the same
73// names on Android so React Native and the native composer use one set of107// names on Android so React Native and the native composer use one set of
74// family names without waiting for runtime font loading.108// family names without waiting for runtime font loading.
...@@ -125,12 +159,16 @@ const config: ExpoConfig = {...@@ -125,12 +159,16 @@ const config: ExpoConfig = {
125 backgroundImage: "./assets/android-icon-background.png",159 backgroundImage: "./assets/android-icon-background.png",
126 monochromeImage: "./assets/android-icon-monochrome.png",160 monochromeImage: "./assets/android-icon-monochrome.png",
127 },161 },
128 predictiveBackGestureEnabled: false,162 // Opts into OnBackInvokedCallback-based back dispatch (Android 13+).
163 // JS back handling survives it via react-native's Android 16 shim plus
164 // withAndroidPredictiveBackCompat on Android 13-15.
165 predictiveBackGestureEnabled: true,
129 },166 },
130 web: {167 web: {
131 favicon: "./assets/favicon.png",168 favicon: "./assets/favicon.png",
132 },169 },
133 plugins: [170 plugins: [
171 "expo-asset",
134 [172 [
135 "expo-font",173 "expo-font",
136 {174 {
...@@ -157,8 +195,23 @@ const config: ExpoConfig = {...@@ -157,8 +195,23 @@ const config: ExpoConfig = {
157 ],195 ],
158 "expo-secure-store",196 "expo-secure-store",
159 "expo-sqlite",197 "expo-sqlite",
160 ["@clerk/expo", { theme: "./clerk-theme.json" }],198 // appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before
199 // plugins earlier in this array, so it cannot strip the entitlement Clerk would add.
200 ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }],
161 "expo-web-browser",201 "expo-web-browser",
202 [
203 "expo-quick-actions",
204 {
205 // Adaptive launcher-shortcut icon; referenced by resource name from
206 // the shortcut items set in src/features/shortcuts.
207 androidIcons: {
208 shortcut_icon: {
209 foregroundImage: "./assets/android-icon-foreground.png",
210 backgroundColor: "#E6F4FE",
211 },
212 },
213 },
214 ],
162 [215 [
163 "expo-camera",216 "expo-camera",
164 {217 {
...@@ -198,31 +251,18 @@ const config: ExpoConfig = {...@@ -198,31 +251,18 @@ const config: ExpoConfig = {
198 // expo-widgets' — its dangerous mod wipes ios/ExpoWidgetsTarget/ (which251 // expo-widgets' — its dangerous mod wipes ios/ExpoWidgetsTarget/ (which
199 // would delete the asset catalog) and its xcodeproj mod creates the widget252 // would delete the asset catalog) and its xcodeproj mod creates the widget
200 // target (which must exist before the compile phase can be attached).253 // target (which must exist before the compile phase can be attached).
201 "./plugins/withWidgetLogoAsset.cjs",254 ...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []),
202 [
203 "expo-widgets",
204 {
205 bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,
206 groupIdentifier: `group.${variant.iosBundleIdentifier}`,
207 enablePushNotifications: true,
208 // Agent activity can update many times an hour; without the
209 // frequent-updates entitlement iOS throttles the update budget sooner.
210 frequentUpdates: true,
211 widgets: [
212 {
213 name: "AgentActivity",
214 displayName: "Agent Activity",
215 description: "Shows the current state of active T3 Code agents.",
216 supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"],
217 },
218 ],
219 },
220 ],
221 "./plugins/withIosSceneLifecycle.cjs",255 "./plugins/withIosSceneLifecycle.cjs",
222 "./plugins/withAndroidCleartextTraffic.cjs",256 "./plugins/withAndroidCleartextTraffic.cjs",
257 "./plugins/withAndroidGradleHeap.cjs",
258 "./plugins/withAndroidModernPopupMenu.cjs",
259 "./plugins/withAndroidModernAlertDialog.cjs",
260 "./plugins/withAndroidPredictiveBackCompat.cjs",
261 ...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []),
223 ],262 ],
224 extra: {263 extra: {
225 appVariant: APP_VARIANT,264 appVariant: APP_VARIANT,
265 iosPersonalTeamBuild: isIosPersonalTeamBuild,
226 relay: {266 relay: {
227 url: repoEnv.T3CODE_RELAY_URL ?? null,267 url: repoEnv.T3CODE_RELAY_URL ?? null,
228 },268 },
apps/mobile/global.css+2-2
...@@ -12,7 +12,7 @@...@@ -12,7 +12,7 @@
12 /* Card / surface */12 /* Card / surface */
13 --color-card: #ffffff;13 --color-card: #ffffff;
14 --color-card-alt: #f5f5f5;14 --color-card-alt: #f5f5f5;
15 --color-card-translucent: rgba(255, 255, 255, 0.94);15 --color-card-translucent: rgba(255, 255, 255, 0.8);
1616
17 /* Text */17 /* Text */
18 --color-foreground: #262626;18 --color-foreground: #262626;
...@@ -106,7 +106,7 @@...@@ -106,7 +106,7 @@
106 /* Card / surface */106 /* Card / surface */
107 --color-card: #171717;107 --color-card: #171717;
108 --color-card-alt: #1c1c1c;108 --color-card-alt: #1c1c1c;
109 --color-card-translucent: rgba(17, 17, 17, 0.94);109 --color-card-translucent: rgba(17, 17, 17, 0.8);
110110
111 /* Text */111 /* Text */
112 --color-foreground: #f5f5f5;112 --color-foreground: #f5f5f5;
apps/mobile/metro.config.js+9
...@@ -6,6 +6,7 @@ const { withUniwindConfig } = require("uniwind/metro");...@@ -6,6 +6,7 @@ const { withUniwindConfig } = require("uniwind/metro");
6/** @type {import("expo/metro-config").MetroConfig} */6/** @type {import("expo/metro-config").MetroConfig} */
7const config = getDefaultConfig(__dirname);7const config = getDefaultConfig(__dirname);
8const workspaceRoot = path.resolve(__dirname, "../..");8const workspaceRoot = path.resolve(__dirname, "../..");
9const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] }));10const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] }));
10const resolveShikiDependencyRoot = (packageName) => {11const resolveShikiDependencyRoot = (packageName) => {
11 const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] });12 const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] });
...@@ -25,6 +26,14 @@ const resolveShikiDependencyRoot = (packageName) => {...@@ -25,6 +26,14 @@ const resolveShikiDependencyRoot = (packageName) => {
25config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])];26config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])];
26config.resolver = {27config.resolver = {
27 ...config.resolver,28 ...config.resolver,
29 blockList: [
30 ...(Array.isArray(config.resolver?.blockList)
31 ? config.resolver.blockList
32 : config.resolver?.blockList
33 ? [config.resolver.blockList]
34 : []),
35 new RegExp(`${escapedWorkspaceRoot}[/\\\\]\\.t3[/\\\\].*`),
36 ],
28 extraNodeModules: {37 extraNodeModules: {
29 // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread38 // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread
30 ...(config.resolver?.extraNodeModules ?? {}),39 ...(config.resolver?.extraNodeModules ?? {}),
apps/mobile/modules/t3-composer-editor/android/build.gradle created+19
...@@ -0,0 +1,19 @@
1apply plugin: 'com.android.library'
2apply plugin: 'org.jetbrains.kotlin.android'
3
4group = 'com.t3tools.composereditor'
5version = '0.0.0'
6
7android {
8 namespace 'expo.modules.t3composereditor'
9 compileSdk rootProject.ext.compileSdkVersion
10
11 defaultConfig {
12 minSdkVersion rootProject.ext.minSdkVersion
13 targetSdkVersion rootProject.ext.targetSdkVersion
14 }
15}
16
17dependencies {
18 implementation project(':expo-modules-core')
19}
apps/mobile/modules/t3-composer-editor/android/src/main/AndroidManifest.xml created+1
...@@ -0,0 +1 @@
1<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt created+72
...@@ -0,0 +1,72 @@
1package expo.modules.t3composereditor
2
3import expo.modules.kotlin.modules.Module
4import expo.modules.kotlin.modules.ModuleDefinition
5
6class T3ComposerEditorModule : Module() {
7 override fun definition() = ModuleDefinition {
8 Name("T3ComposerEditor")
9
10 View(T3ComposerEditorView::class) {
11 Prop("controlledDocumentJson") { view: T3ComposerEditorView, documentJson: String ->
12 view.setControlledDocumentJson(documentJson)
13 }
14 Prop("themeJson") { view: T3ComposerEditorView, themeJson: String ->
15 view.setThemeJson(themeJson)
16 }
17 Prop("placeholder") { view: T3ComposerEditorView, placeholder: String ->
18 view.setPlaceholder(placeholder)
19 }
20 Prop("fontFamily") { view: T3ComposerEditorView, fontFamily: String ->
21 view.setFontFamily(fontFamily)
22 }
23 Prop("fontSize") { view: T3ComposerEditorView, fontSize: Double ->
24 view.setFontSize(fontSize.toFloat())
25 }
26 Prop("lineHeight") { view: T3ComposerEditorView, lineHeight: Double ->
27 view.setLineHeight(lineHeight.toFloat())
28 }
29 Prop("contentInsetVertical") { view: T3ComposerEditorView, contentInsetVertical: Double ->
30 view.setContentInsetVertical(contentInsetVertical.toInt())
31 }
32
33 Prop("singleLineCentered") { view: T3ComposerEditorView, singleLineCentered: Boolean ->
34 view.setSingleLineCentered(singleLineCentered)
35 }
36 Prop("editable") { view: T3ComposerEditorView, editable: Boolean ->
37 view.setEditable(editable)
38 }
39 Prop("scrollEnabled") { view: T3ComposerEditorView, scrollEnabled: Boolean ->
40 view.setScrollEnabled(scrollEnabled)
41 }
42 Prop("autoFocus") { view: T3ComposerEditorView, autoFocus: Boolean ->
43 view.setAutoFocus(autoFocus)
44 }
45 Prop("autoCorrect") { view: T3ComposerEditorView, autoCorrect: Boolean ->
46 view.setAutoCorrect(autoCorrect)
47 }
48 Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean ->
49 view.setSpellCheck(spellCheck)
50 }
51
52 Events(
53 "onComposerChange",
54 "onComposerSelectionChange",
55 "onComposerFocus",
56 "onComposerBlur",
57 "onComposerPasteImages",
58 "onComposerContentSizeChange",
59 )
60
61 AsyncFunction("focus") { view: T3ComposerEditorView ->
62 view.focusEditor()
63 }
64 AsyncFunction("blur") { view: T3ComposerEditorView ->
65 view.blurEditor()
66 }
67 AsyncFunction("setSelection") { view: T3ComposerEditorView, start: Int, end: Int ->
68 view.setSelection(start, end)
69 }
70 }
71 }
72}
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt created+480
...@@ -0,0 +1,480 @@
1package expo.modules.t3composereditor
2
3import android.content.Context
4import android.content.ClipboardManager
5import android.graphics.Color
6import android.graphics.Canvas
7import android.graphics.Paint
8import android.graphics.RectF
9import android.graphics.Typeface
10import android.text.Editable
11import android.text.InputType
12import android.text.Spanned
13import android.text.TextWatcher
14import android.text.style.ReplacementSpan
15import android.view.Gravity
16import android.view.ViewGroup
17import android.view.inputmethod.InputMethodManager
18import android.widget.EditText
19import expo.modules.kotlin.AppContext
20import expo.modules.kotlin.viewevent.EventDispatcher
21import expo.modules.kotlin.views.ExpoView
22import org.json.JSONObject
23import kotlin.math.max
24
25class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
26 context,
27 appContext
28) {
29 private val editor = SelectionAwareEditText(context)
30 private val onComposerChange by EventDispatcher()
31 private val onComposerSelectionChange by EventDispatcher()
32 private val onComposerFocus by EventDispatcher()
33 private val onComposerBlur by EventDispatcher()
34 private val onComposerPasteImages by EventDispatcher()
35 private val onComposerContentSizeChange by EventDispatcher()
36 private var applyingNativeValue = false
37 private var desiredLineHeightPx = 0
38 private var lastContentHeight = 0
39 private var contentInsetVertical = 0
40 private var tokensJson = "[]"
41 private var tokens: List<ComposerToken> = emptyList()
42 private var chipTheme = ComposerChipTheme.default()
43 private var autoCorrect = true
44 private var spellCheck = true
45 private var nativeEventCount = 0
46
47 init {
48 editor.setBackgroundColor(Color.TRANSPARENT)
49 editor.gravity = Gravity.TOP or Gravity.START
50 editor.includeFontPadding = false
51 editor.isSingleLine = false
52 editor.minLines = 1
53 editor.inputType =
54 InputType.TYPE_CLASS_TEXT or
55 InputType.TYPE_TEXT_FLAG_MULTI_LINE or
56 InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
57 editor.setTextColor(Color.BLACK)
58 editor.setHintTextColor(Color.GRAY)
59 editor.setPadding(0, 0, 0, 0)
60 editor.selectionListener = { start, end ->
61 if (!applyingNativeValue) {
62 emitSelectionChange(start, end)
63 }
64 }
65 editor.pasteImagesListener = { uris ->
66 onComposerPasteImages(mapOf("uris" to uris))
67 }
68 editor.setOnFocusChangeListener { _, hasFocus ->
69 if (hasFocus) {
70 onComposerFocus(emptyMap<String, Any>())
71 } else {
72 onComposerBlur(emptyMap<String, Any>())
73 }
74 }
75 editor.addTextChangedListener(
76 object : TextWatcher {
77 override fun beforeTextChanged(
78 text: CharSequence?,
79 start: Int,
80 count: Int,
81 after: Int
82 ) = Unit
83 override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit
84
85 override fun afterTextChanged(editable: Editable?) {
86 if (applyingNativeValue) return
87 val nextValue = editable.toString()
88 val selection = currentSelectionPayload()
89 nativeEventCount += 1
90 onComposerChange(
91 mapOf(
92 "value" to nextValue,
93 "selection" to selection,
94 "eventCount" to nativeEventCount,
95 ),
96 )
97 emitContentSizeIfNeeded()
98 }
99 },
100 )
101 editor.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> emitContentSizeIfNeeded() }
102 addView(
103 editor,
104 LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
105 )
106 }
107
108 @Suppress("ReturnCount")
109 fun setControlledDocumentJson(documentJson: String) {
110 val document = try {
111 JSONObject(documentJson)
112 } catch (_: Exception) {
113 return
114 }
115 val mostRecentEventCount = document.optInt("mostRecentEventCount", -1)
116 if (mostRecentEventCount < nativeEventCount) return
117
118 val value = document.optString("value")
119 if (document.optBoolean("isNativeEcho") && editor.text.toString() != value) return
120
121 val nextTokensJson = document.optString("tokensJson", "[]")
122 val nextTokens = if (nextTokensJson == tokensJson) tokens else parseTokens(nextTokensJson)
123 val requestedSelection = document.optJSONObject("selection")
124 val previousSelectionStart = editor.selectionStart.coerceAtLeast(0)
125 val previousSelectionEnd = editor.selectionEnd.coerceAtLeast(0)
126 val valueChanged = editor.text.toString() != value
127
128 applyingNativeValue = true
129 try {
130 if (valueChanged) {
131 editor.setText(value)
132 }
133 tokensJson = nextTokensJson
134 tokens = nextTokens
135 applyTokenSpans()
136 if (requestedSelection != null) {
137 applySelection(
138 requestedSelection.optInt("start", previousSelectionStart),
139 requestedSelection.optInt("end", previousSelectionEnd),
140 )
141 } else if (valueChanged) {
142 applySelection(previousSelectionStart, previousSelectionEnd)
143 }
144 } finally {
145 applyingNativeValue = false
146 }
147 emitContentSizeIfNeeded()
148 }
149
150 fun setThemeJson(themeJson: String) {
151 try {
152 val theme = JSONObject(themeJson)
153 editor.setTextColor(parseColor(theme.optString("text"), Color.BLACK))
154 editor.setHintTextColor(parseColor(theme.optString("placeholder"), Color.GRAY))
155 chipTheme = ComposerChipTheme(
156 chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground),
157 chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder),
158 chipText = parseColor(theme.optString("chipText"), chipTheme.chipText),
159 skillBackground = parseColor(
160 theme.optString("skillBackground"),
161 chipTheme.skillBackground,
162 ),
163 skillBorder = parseColor(theme.optString("skillBorder"), chipTheme.skillBorder),
164 skillText = parseColor(theme.optString("skillText"), chipTheme.skillText),
165 )
166 applyTokenSpans()
167 } catch (_: Exception) {
168 }
169 }
170
171 fun setPlaceholder(placeholder: String) {
172 editor.hint = placeholder
173 }
174
175 fun setFontFamily(fontFamily: String) {
176 editor.typeface = if (fontFamily.contains("Mono", ignoreCase = true)) {
177 Typeface.MONOSPACE
178 } else {
179 Typeface.DEFAULT
180 }
181 }
182
183 fun setFontSize(fontSize: Float) {
184 editor.textSize = fontSize
185 applyLineHeight()
186 }
187
188 fun setLineHeight(lineHeight: Float) {
189 desiredLineHeightPx = (lineHeight * resources.displayMetrics.density).toInt()
190 applyLineHeight()
191 }
192
193 fun setSingleLineCentered(centered: Boolean) {
194 editor.gravity = if (centered) {
195 Gravity.CENTER_VERTICAL or Gravity.START
196 } else {
197 Gravity.TOP or Gravity.START
198 }
199 }
200
201 fun setContentInsetVertical(contentInsetVertical: Int) {
202 this.contentInsetVertical =
203 max(0, (contentInsetVertical * resources.displayMetrics.density).toInt())
204 editor.setPadding(0, this.contentInsetVertical, 0, this.contentInsetVertical)
205 emitContentSizeIfNeeded()
206 }
207
208 fun setEditable(editable: Boolean) {
209 editor.isEnabled = editable
210 editor.isFocusable = editable
211 editor.isFocusableInTouchMode = editable
212 editor.isCursorVisible = editable
213 }
214
215 fun setScrollEnabled(scrollEnabled: Boolean) {
216 editor.isVerticalScrollBarEnabled = scrollEnabled
217 }
218
219 fun setAutoFocus(autoFocus: Boolean) {
220 if (autoFocus) {
221 post { focusEditor() }
222 }
223 }
224
225 fun setAutoCorrect(autoCorrect: Boolean) {
226 this.autoCorrect = autoCorrect
227 updateInputFlags()
228 }
229
230 fun setSpellCheck(spellCheck: Boolean) {
231 this.spellCheck = spellCheck
232 updateInputFlags()
233 }
234
235 fun focusEditor() {
236 editor.requestFocus()
237 val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
238 imm?.showSoftInput(editor, InputMethodManager.SHOW_IMPLICIT)
239 }
240
241 fun blurEditor() {
242 editor.clearFocus()
243 val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
244 imm?.hideSoftInputFromWindow(editor.windowToken, 0)
245 }
246
247 fun setSelection(start: Int, end: Int) {
248 applySelection(start, end)
249 }
250
251 private fun applySelection(start: Int, end: Int) {
252 val textLength = editor.text?.length ?: 0
253 val safeStart = start.coerceIn(0, textLength)
254 val safeEnd = end.coerceIn(0, textLength)
255 editor.setSelection(safeStart, safeEnd)
256 }
257
258 private fun updateInputFlags() {
259 var flags =
260 InputType.TYPE_CLASS_TEXT or
261 InputType.TYPE_TEXT_FLAG_MULTI_LINE or
262 InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
263 flags = if (autoCorrect && spellCheck) {
264 flags or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
265 } else {
266 flags or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
267 }
268 editor.inputType = flags
269 }
270
271 private fun applyLineHeight() {
272 if (desiredLineHeightPx <= 0) return
273 val fontHeight = editor.paint.fontMetricsInt.descent - editor.paint.fontMetricsInt.ascent
274 editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f)
275 }
276
277 private fun currentSelectionPayload(): Map<String, Int> =
278 mapOf(
279 "start" to editor.selectionStart.coerceAtLeast(0),
280 "end" to editor.selectionEnd.coerceAtLeast(0),
281 )
282
283 private fun emitSelectionChange(start: Int, end: Int) {
284 onComposerSelectionChange(
285 mapOf(
286 "value" to editor.text.toString(),
287 "selection" to mapOf("start" to start, "end" to end),
288 "eventCount" to nativeEventCount,
289 ),
290 )
291 }
292
293 private fun emitContentSizeIfNeeded() {
294 val height = editor.layout?.height ?: editor.measuredHeight
295 val contentHeight = height + contentInsetVertical * 2
296 if (contentHeight == lastContentHeight) return
297 lastContentHeight = contentHeight
298 onComposerContentSizeChange(
299 mapOf("height" to contentHeight / resources.displayMetrics.density),
300 )
301 }
302
303 private fun applyTokenSpans() {
304 val editable = editor.text ?: return
305 editable.getSpans(
306 0,
307 editable.length,
308 ComposerChipSpan::class.java
309 ).forEach(editable::removeSpan)
310 tokens.forEach { token ->
311 if (token.start < 0 || token.end <= token.start || token.end > editable.length) return@forEach
312 val expectedSource = editable.substring(token.start, token.end)
313 if (expectedSource != token.source) return@forEach
314 editable.setSpan(
315 ComposerChipSpan(
316 token.label,
317 token.type == "skill",
318 chipTheme,
319 resources.displayMetrics.density
320 ),
321 token.start,
322 token.end,
323 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
324 )
325 }
326 editor.invalidate()
327 }
328
329 private fun parseColor(value: String, fallback: Int): Int =
330 try {
331 Color.parseColor(value)
332 } catch (_: Exception) {
333 fallback
334 }
335}
336
337private data class ComposerToken(
338 val type: String,
339 val source: String,
340 val label: String,
341 val start: Int,
342 val end: Int
343)
344
345private data class ComposerChipTheme(
346 val chipBackground: Int,
347 val chipBorder: Int,
348 val chipText: Int,
349 val skillBackground: Int,
350 val skillBorder: Int,
351 val skillText: Int
352) {
353 companion object {
354 fun default() = ComposerChipTheme(
355 chipBackground = Color.rgb(238, 240, 243),
356 chipBorder = Color.rgb(210, 214, 220),
357 chipText = Color.rgb(35, 39, 45),
358 skillBackground = Color.rgb(233, 239, 255),
359 skillBorder = Color.rgb(185, 200, 245),
360 skillText = Color.rgb(45, 72, 155),
361 )
362 }
363}
364
365private class ComposerChipSpan(
366 private val label: String,
367 private val skill: Boolean,
368 private val theme: ComposerChipTheme,
369 density: Float
370) : ReplacementSpan() {
371 private val horizontalPadding = 7f * density
372 private val verticalPadding = 2f * density
373 private val cornerRadius = 6f * density
374 private val borderWidth = density
375
376 override fun getSize(
377 paint: Paint,
378 text: CharSequence,
379 start: Int,
380 end: Int,
381 fontMetrics: Paint.FontMetricsInt?
382 ): Int {
383 fontMetrics?.let {
384 val extra = verticalPadding.toInt()
385 val base = paint.fontMetricsInt
386 it.top = base.top - extra
387 it.ascent = base.ascent - extra
388 it.descent = base.descent + extra
389 it.bottom = base.bottom + extra
390 }
391 return (paint.measureText(label) + horizontalPadding * 2).toInt()
392 }
393
394 override fun draw(
395 canvas: Canvas,
396 text: CharSequence,
397 start: Int,
398 end: Int,
399 x: Float,
400 top: Int,
401 y: Int,
402 bottom: Int,
403 paint: Paint
404 ) {
405 val width = paint.measureText(label) + horizontalPadding * 2
406 val metrics = paint.fontMetrics
407 val rect = RectF(
408 x,
409 y + metrics.ascent - verticalPadding,
410 x + width,
411 y + metrics.descent + verticalPadding,
412 )
413 val originalColor = paint.color
414 val originalStyle = paint.style
415 val originalStrokeWidth = paint.strokeWidth
416
417 paint.color = if (skill) theme.skillBackground else theme.chipBackground
418 paint.style = Paint.Style.FILL
419 canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint)
420 paint.color = if (skill) theme.skillBorder else theme.chipBorder
421 paint.style = Paint.Style.STROKE
422 paint.strokeWidth = borderWidth
423 canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint)
424 paint.color = if (skill) theme.skillText else theme.chipText
425 paint.style = Paint.Style.FILL
426 canvas.drawText(label, x + horizontalPadding, y.toFloat(), paint)
427
428 paint.color = originalColor
429 paint.style = originalStyle
430 paint.strokeWidth = originalStrokeWidth
431 }
432}
433
434private fun parseTokens(value: String): List<ComposerToken> = try {
435 val array = org.json.JSONArray(value)
436 List(array.length()) { index ->
437 val token = array.getJSONObject(index)
438 ComposerToken(
439 type = token.optString("type"),
440 source = token.optString("source"),
441 label = token.optString("label"),
442 start = token.optInt("start"),
443 end = token.optInt("end"),
444 )
445 }
446} catch (_: Exception) {
447 emptyList()
448}
449
450private class SelectionAwareEditText(context: Context) : EditText(context) {
451 var selectionListener: ((Int, Int) -> Unit)? = null
452 var pasteImagesListener: ((List<String>) -> Unit)? = null
453
454 override fun onSelectionChanged(selStart: Int, selEnd: Int) {
455 super.onSelectionChanged(selStart, selEnd)
456 selectionListener?.invoke(selStart, selEnd)
457 }
458
459 override fun onTextContextMenuItem(id: Int): Boolean {
460 if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) {
461 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
462 val clip = clipboard?.primaryClip
463 val imageUris = buildList {
464 if (clip != null) {
465 for (index in 0 until clip.itemCount) {
466 clip.getItemAt(index).uri?.let { uri ->
467 val mimeType = context.contentResolver.getType(uri)
468 if (mimeType?.startsWith("image/") == true) add(uri.toString())
469 }
470 }
471 }
472 }
473 if (imageUris.isNotEmpty()) {
474 pasteImagesListener?.invoke(imageUris)
475 return true
476 }
477 }
478 return super.onTextContextMenuItem(id)
479 }
480}
apps/mobile/modules/t3-composer-editor/expo-module.config.json+4-1
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1{1{
2 "platforms": ["apple"],2 "platforms": ["apple", "android"],
3 "apple": {3 "apple": {
4 "modules": ["T3ComposerEditorModule"]4 "modules": ["T3ComposerEditorModule"]
5 },
6 "android": {
7 "modules": ["expo.modules.t3composereditor.T3ComposerEditorModule"]
5 }8 }
6}9}
apps/mobile/modules/t3-native-controls/android/build.gradle created+19
...@@ -0,0 +1,19 @@
1apply plugin: 'com.android.library'
2apply plugin: 'org.jetbrains.kotlin.android'
3
4group = 'com.t3tools.nativecontrols'
5version = '0.0.0'
6
7android {
8 namespace 'expo.modules.t3nativecontrols'
9 compileSdk rootProject.ext.compileSdkVersion
10
11 defaultConfig {
12 minSdkVersion rootProject.ext.minSdkVersion
13 targetSdkVersion rootProject.ext.targetSdkVersion
14 }
15}
16
17dependencies {
18 implementation project(':expo-modules-core')
19}
apps/mobile/modules/t3-native-controls/android/src/main/AndroidManifest.xml created+1
...@@ -0,0 +1 @@
1<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt created+97
...@@ -0,0 +1,97 @@
1package expo.modules.t3nativecontrols
2
3import android.content.Context
4import android.graphics.Canvas
5import android.graphics.Color
6import android.graphics.Paint
7import android.view.View
8import expo.modules.kotlin.AppContext
9import expo.modules.kotlin.viewevent.EventDispatcher
10import expo.modules.kotlin.views.ExpoView
11
12class T3HeaderButtonView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
13 private val iconView = HeaderIconView(context)
14 private val onTriggered by EventDispatcher()
15
16 init {
17 isClickable = true
18 isFocusable = true
19 setOnClickListener {
20 onTriggered(emptyMap<String, Any>())
21 }
22 addView(iconView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
23 }
24
25 fun setLabel(label: String) {
26 contentDescription = label
27 }
28
29 fun setSystemImage(systemImage: String) {
30 iconView.systemImage = systemImage
31 }
32}
33
34private class HeaderIconView(context: Context) : View(context) {
35 private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
36 color = Color.parseColor("#6B7280")
37 strokeCap = Paint.Cap.ROUND
38 strokeJoin = Paint.Join.ROUND
39 strokeWidth = 3f * resources.displayMetrics.density
40 style = Paint.Style.STROKE
41 }
42
43 var systemImage: String = "gearshape"
44 set(value) {
45 field = value
46 invalidate()
47 }
48
49 override fun onDraw(canvas: Canvas) {
50 super.onDraw(canvas)
51 val cx = width / 2f
52 val cy = height / 2f
53 val size = minOf(width, height).toFloat()
54 if (systemImage == "square.and.pencil") {
55 drawNewTask(canvas, cx, cy, size)
56 } else {
57 drawSettings(canvas, cx, cy, size)
58 }
59 }
60
61 private fun drawSettings(canvas: Canvas, cx: Float, cy: Float, size: Float) {
62 val radius = size * 0.12f
63 canvas.drawCircle(cx, cy, radius, paint)
64 for (index in 0 until 8) {
65 val angle = Math.PI * index / 4.0
66 val inner = size * 0.19f
67 val outer = size * 0.27f
68 val sx = cx + kotlin.math.cos(angle).toFloat() * inner
69 val sy = cy + kotlin.math.sin(angle).toFloat() * inner
70 val ex = cx + kotlin.math.cos(angle).toFloat() * outer
71 val ey = cy + kotlin.math.sin(angle).toFloat() * outer
72 canvas.drawLine(sx, sy, ex, ey, paint)
73 }
74 }
75
76 private fun drawNewTask(canvas: Canvas, cx: Float, cy: Float, size: Float) {
77 val left = cx - size * 0.2f
78 val top = cy - size * 0.16f
79 val right = cx + size * 0.14f
80 val bottom = cy + size * 0.2f
81 canvas.drawRoundRect(left, top, right, bottom, size * 0.04f, size * 0.04f, paint)
82 canvas.drawLine(
83 cx - size * 0.02f,
84 cy + size * 0.13f,
85 cx + size * 0.24f,
86 cy - size * 0.13f,
87 paint
88 )
89 canvas.drawLine(
90 cx + size * 0.17f,
91 cy - size * 0.2f,
92 cx + size * 0.24f,
93 cy - size * 0.13f,
94 paint
95 )
96 }
97}
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt created+21
...@@ -0,0 +1,21 @@
1package expo.modules.t3nativecontrols
2
3import expo.modules.kotlin.modules.Module
4import expo.modules.kotlin.modules.ModuleDefinition
5
6class T3NativeControlsModule : Module() {
7 override fun definition() = ModuleDefinition {
8 Name("T3NativeControls")
9
10 View(T3HeaderButtonView::class) {
11 Prop("label") { view: T3HeaderButtonView, label: String ->
12 view.setLabel(label)
13 }
14 Prop("systemImage") { view: T3HeaderButtonView, systemImage: String ->
15 view.setSystemImage(systemImage)
16 }
17
18 Events("onTriggered")
19 }
20 }
21}
apps/mobile/modules/t3-native-controls/expo-module.config.json+4-1
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1{1{
2 "platforms": ["apple"],2 "platforms": ["apple", "android"],
3 "apple": {3 "apple": {
4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"]4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"]
5 },
6 "android": {
7 "modules": ["expo.modules.t3nativecontrols.T3NativeControlsModule"]
5 }8 }
6}9}
apps/mobile/modules/t3-review-diff/android/build.gradle created+19
...@@ -0,0 +1,19 @@
1apply plugin: 'com.android.library'
2apply plugin: 'org.jetbrains.kotlin.android'
3
4group = 'com.t3tools.reviewdiff'
5version = '0.0.0'
6
7android {
8 namespace 'expo.modules.t3reviewdiff'
9 compileSdk rootProject.ext.compileSdkVersion
10
11 defaultConfig {
12 minSdkVersion rootProject.ext.minSdkVersion
13 targetSdkVersion rootProject.ext.targetSdkVersion
14 }
15}
16
17dependencies {
18 implementation project(':expo-modules-core')
19}
apps/mobile/modules/t3-review-diff/android/src/main/AndroidManifest.xml created+1
...@@ -0,0 +1 @@
1<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt created+78
...@@ -0,0 +1,78 @@
1package expo.modules.t3reviewdiff
2
3import expo.modules.kotlin.modules.Module
4import expo.modules.kotlin.modules.ModuleDefinition
5
6class T3ReviewDiffModule : Module() {
7 override fun definition() = ModuleDefinition {
8 Name("T3ReviewDiffSurface")
9
10 View(T3ReviewDiffView::class) {
11 Prop("tokensResetKey") { view: T3ReviewDiffView, tokensResetKey: String ->
12 view.setTokensResetKey(tokensResetKey)
13 }
14 Prop("contentResetKey") { view: T3ReviewDiffView, contentResetKey: String ->
15 view.setContentResetKey(contentResetKey)
16 }
17 Prop("collapsedFileIdsJson") { view: T3ReviewDiffView, collapsedFileIdsJson: String ->
18 view.setCollapsedFileIdsJson(collapsedFileIdsJson)
19 }
20 Prop("viewedFileIdsJson") { view: T3ReviewDiffView, viewedFileIdsJson: String ->
21 view.setViewedFileIdsJson(viewedFileIdsJson)
22 }
23 Prop("selectedRowIdsJson") { view: T3ReviewDiffView, selectedRowIdsJson: String ->
24 view.setSelectedRowIdsJson(selectedRowIdsJson)
25 }
26 Prop("collapsedCommentIdsJson") { view: T3ReviewDiffView, collapsedCommentIdsJson: String ->
27 view.setCollapsedCommentIdsJson(collapsedCommentIdsJson)
28 }
29 Prop("appearanceScheme") { view: T3ReviewDiffView, appearanceScheme: String ->
30 view.setAppearanceScheme(appearanceScheme)
31 }
32 Prop("themeJson") { view: T3ReviewDiffView, themeJson: String ->
33 view.setThemeJson(themeJson)
34 }
35 Prop("styleJson") { view: T3ReviewDiffView, styleJson: String ->
36 view.setStyleJson(styleJson)
37 }
38 Prop("rowHeight") { view: T3ReviewDiffView, rowHeight: Double ->
39 view.setRowHeight(rowHeight.toFloat())
40 }
41 Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double ->
42 view.setContentWidth(contentWidth.toFloat())
43 }
44 Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double ->
45 view.setInitialRowIndex(initialRowIndex)
46 }
47
48 Events(
49 "onDebug",
50 "onVisibleFileChange",
51 "onToggleFile",
52 "onToggleViewedFile",
53 "onPressLine",
54 "onToggleComment",
55 )
56
57 AsyncFunction("scrollToFile") { view: T3ReviewDiffView, fileId: String, animated: Boolean ->
58 view.scrollToFile(fileId, animated)
59 }
60 AsyncFunction("scrollToTop") { view: T3ReviewDiffView, animated: Boolean ->
61 view.scrollToTop(animated)
62 }
63 AsyncFunction("setRowsJson") { view: T3ReviewDiffView, rowsJson: String ->
64 view.setRowsJson(rowsJson)
65 }
66 AsyncFunction("setTokensJson") { view: T3ReviewDiffView, tokensJson: String ->
67 view.setTokensJson(tokensJson)
68 }
69 AsyncFunction("setTokensPatchJson") { view: T3ReviewDiffView, tokensPatchJson: String ->
70 view.setTokensPatchJson(tokensPatchJson)
71 }
72
73 OnViewDestroys { view: T3ReviewDiffView ->
74 view.cleanup()
75 }
76 }
77 }
78}
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt created+1056
...@@ -0,0 +1,1056 @@
1package expo.modules.t3reviewdiff
2
3import android.content.Context
4import android.graphics.Canvas
5import android.graphics.Color
6import android.graphics.Paint
7import android.graphics.Typeface
8import android.view.GestureDetector
9import android.view.MotionEvent
10import android.view.VelocityTracker
11import android.view.View
12import android.view.ViewGroup
13import android.view.ViewConfiguration
14import android.widget.OverScroller
15import expo.modules.kotlin.AppContext
16import expo.modules.kotlin.viewevent.EventDispatcher
17import expo.modules.kotlin.views.ExpoView
18import org.json.JSONArray
19import org.json.JSONObject
20import java.util.concurrent.Executors
21import kotlin.math.abs
22import kotlin.math.max
23import kotlin.math.min
24
25class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
26 private val canvasView = DiffCanvasView(context)
27 private val onDebug by EventDispatcher()
28 private val onVisibleFileChange by EventDispatcher()
29 private val onToggleFile by EventDispatcher()
30 private val onToggleViewedFile by EventDispatcher()
31 private val onPressLine by EventDispatcher()
32 private val onToggleComment by EventDispatcher()
33 private var rows: List<DiffRow> = emptyList()
34 private var visibleRows: List<DiffRow> = emptyList()
35 private var collapsedFileIds: Set<String> = emptySet()
36 private var viewedFileIds: Set<String> = emptySet()
37 private var selectedRowIds: Set<String> = emptySet()
38 private var collapsedCommentIds: Set<String> = emptySet()
39 private var initialRowIndex = 0
40 private var pendingInitialScroll = false
41 private var lastVisibleFileId: String? = null
42 private var tokensResetKey = ""
43 private var contentResetKey = ""
44 private var rowsDecodeGeneration = 0
45 private var tokensDecodeGeneration = 0
46 private val payloadDecodeExecutor = Executors.newSingleThreadExecutor()
47 private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
48 private val minimumFlingVelocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity
49 private val verticalScroller = OverScroller(context)
50 private val horizontalScroller = OverScroller(context)
51 private var dragAxis: DragAxis? = null
52 private var lastTouchX = 0f
53 private var lastTouchY = 0f
54 private var velocityTracker: VelocityTracker? = null
55
56 init {
57 canvasView.onRowTap = { row, gesture -> handleRowTap(row, gesture) }
58 canvasView.onVisibleRowsChanged = { first, last ->
59 onDebug(
60 mapOf(
61 "message" to "visible-range",
62 "firstRowIndex" to first,
63 "lastRowIndex" to last,
64 ),
65 )
66 emitVisibleFile(first)
67 }
68
69 addView(
70 canvasView,
71 LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
72 )
73 }
74
75 fun setTokensResetKey(value: String) {
76 if (tokensResetKey == value) return
77 tokensResetKey = value
78 canvasView.tokensByRowId = emptyMap()
79 }
80
81 fun setContentResetKey(value: String) {
82 if (contentResetKey == value) return
83 contentResetKey = value
84 tokensDecodeGeneration += 1
85 canvasView.tokensByRowId = emptyMap()
86 lastVisibleFileId = null
87 pendingInitialScroll = true
88 canvasView.setVerticalOffset(0)
89 canvasView.setHorizontalOffset(0)
90 applyPendingInitialScroll()
91 }
92
93 fun setCollapsedFileIdsJson(value: String) {
94 collapsedFileIds = parseStringSet(value)
95 rebuildVisibleRows()
96 }
97
98 fun setViewedFileIdsJson(value: String) {
99 viewedFileIds = parseStringSet(value)
100 canvasView.viewedFileIds = viewedFileIds
101 }
102
103 fun setSelectedRowIdsJson(value: String) {
104 selectedRowIds = parseStringSet(value)
105 canvasView.selectedRowIds = selectedRowIds
106 }
107
108 fun setCollapsedCommentIdsJson(value: String) {
109 collapsedCommentIds = parseStringSet(value)
110 rebuildVisibleRows()
111 }
112
113 fun setAppearanceScheme(value: String) {
114 canvasView.theme = DiffTheme.fallback(value)
115 }
116
117 fun setThemeJson(value: String) {
118 canvasView.theme = DiffTheme.fromJson(value, canvasView.theme)
119 }
120
121 fun setStyleJson(value: String) {
122 canvasView.style = DiffStyle.fromJson(value, canvasView.style, resources.displayMetrics.density)
123 }
124
125 fun setRowHeight(value: Float) {
126 canvasView.style = canvasView.style.copy(rowHeightPx = dp(value))
127 }
128
129 fun setContentWidth(value: Float) {
130 canvasView.contentWidthPx = max(width, dp(value).toInt())
131 }
132
133 fun setInitialRowIndex(value: Double) {
134 initialRowIndex = value.toInt().coerceAtLeast(0)
135 pendingInitialScroll = true
136 applyPendingInitialScroll()
137 }
138
139 fun setRowsJson(value: String) {
140 rowsDecodeGeneration += 1
141 val generation = rowsDecodeGeneration
142 payloadDecodeExecutor.execute {
143 val decodedRows = parseRows(value)
144 post {
145 if (generation != rowsDecodeGeneration) return@post
146 rows = decodedRows
147 lastVisibleFileId = null
148 rebuildVisibleRows()
149 }
150 }
151 }
152
153 fun setTokensJson(value: String) {
154 tokensDecodeGeneration += 1
155 val generation = tokensDecodeGeneration
156 payloadDecodeExecutor.execute {
157 val decodedTokens = parseTokensObject(value)
158 post {
159 if (generation != tokensDecodeGeneration) return@post
160 canvasView.tokensByRowId = decodedTokens
161 }
162 }
163 }
164
165 fun setTokensPatchJson(value: String) {
166 payloadDecodeExecutor.execute {
167 try {
168 val payload = JSONObject(value)
169 val resetKey = payload.optString("resetKey")
170 val decodedTokens = parseTokensObject(
171 payload.optJSONObject("tokensByRowId") ?: JSONObject(),
172 )
173 post {
174 if (resetKey.isNotEmpty() && resetKey != tokensResetKey) return@post
175 if (decodedTokens.isNotEmpty()) {
176 canvasView.tokensByRowId = canvasView.tokensByRowId + decodedTokens
177 }
178 }
179 } catch (_: Exception) {
180 }
181 }
182 }
183
184 fun cleanup() {
185 payloadDecodeExecutor.shutdownNow()
186 }
187
188 fun scrollToFile(fileId: String, animated: Boolean) {
189 val index = visibleRows.indexOfFirst { it.kind == "file" && it.resolvedFileId == fileId }
190 if (index < 0) return
191 scrollToY(canvasView.rowTop(index), animated)
192 }
193
194 fun scrollToTop(animated: Boolean) {
195 scrollToY(0, animated)
196 }
197
198 @Suppress("NestedBlockDepth", "ReturnCount")
199 override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
200 when (event.actionMasked) {
201 MotionEvent.ACTION_DOWN -> {
202 verticalScroller.forceFinished(true)
203 horizontalScroller.forceFinished(true)
204 dragAxis = null
205 lastTouchX = event.x
206 lastTouchY = event.y
207 parent?.requestDisallowInterceptTouchEvent(true)
208 return false
209 }
210 MotionEvent.ACTION_MOVE -> {
211 if (dragAxis == null) {
212 val deltaX = event.x - lastTouchX
213 val deltaY = event.y - lastTouchY
214 if (max(abs(deltaX), abs(deltaY)) > touchSlop) {
215 dragAxis = if (abs(deltaY) >= abs(deltaX)) DragAxis.VERTICAL else DragAxis.HORIZONTAL
216 }
217 }
218 return dragAxis != null
219 }
220 MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return dragAxis != null
221 }
222 return false
223 }
224
225 override fun dispatchTouchEvent(event: MotionEvent): Boolean {
226 if (event.actionMasked == MotionEvent.ACTION_DOWN) {
227 velocityTracker?.recycle()
228 velocityTracker = VelocityTracker.obtain()
229 }
230 velocityTracker?.addMovement(event)
231 val handled = super.dispatchTouchEvent(event)
232 if (
233 (
234 event.actionMasked == MotionEvent.ACTION_UP ||
235 event.actionMasked == MotionEvent.ACTION_CANCEL
236 ) &&
237 dragAxis == null
238 ) {
239 velocityTracker?.recycle()
240 velocityTracker = null
241 parent?.requestDisallowInterceptTouchEvent(false)
242 }
243 return handled
244 }
245
246 @Suppress("NestedBlockDepth")
247 override fun onTouchEvent(event: MotionEvent): Boolean {
248 val axis = dragAxis ?: return false
249 when (event.actionMasked) {
250 MotionEvent.ACTION_MOVE -> {
251 val deltaX = (lastTouchX - event.x).toInt()
252 val deltaY = (lastTouchY - event.y).toInt()
253 if (axis == DragAxis.VERTICAL) {
254 canvasView.scrollByVertical(deltaY)
255 } else {
256 canvasView.scrollByHorizontal(deltaX)
257 }
258 lastTouchX = event.x
259 lastTouchY = event.y
260 }
261 MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
262 if (event.actionMasked == MotionEvent.ACTION_UP) {
263 velocityTracker?.computeCurrentVelocity(1000)
264 if (axis == DragAxis.VERTICAL) {
265 val velocity = -(velocityTracker?.yVelocity ?: 0f).toInt()
266 if (abs(velocity) >= minimumFlingVelocity) {
267 verticalScroller.fling(
268 0,
269 canvasView.verticalOffset(),
270 0,
271 velocity,
272 0,
273 0,
274 0,
275 canvasView.maxVerticalOffset(),
276 )
277 postInvalidateOnAnimation()
278 }
279 } else {
280 val velocity = -(velocityTracker?.xVelocity ?: 0f).toInt()
281 if (abs(velocity) >= minimumFlingVelocity) {
282 horizontalScroller.fling(
283 canvasView.horizontalOffset(),
284 0,
285 velocity,
286 0,
287 0,
288 canvasView.maxHorizontalOffset(),
289 0,
290 0,
291 )
292 postInvalidateOnAnimation()
293 }
294 }
295 }
296 dragAxis = null
297 velocityTracker?.recycle()
298 velocityTracker = null
299 parent?.requestDisallowInterceptTouchEvent(false)
300 }
301 }
302 return true
303 }
304
305 override fun computeScroll() {
306 var animating = false
307 if (verticalScroller.computeScrollOffset()) {
308 canvasView.setVerticalOffset(verticalScroller.currY)
309 animating = true
310 }
311 if (horizontalScroller.computeScrollOffset()) {
312 canvasView.setHorizontalOffset(horizontalScroller.currX)
313 animating = true
314 }
315 if (animating) {
316 postInvalidateOnAnimation()
317 }
318 }
319
320 private fun rebuildVisibleRows() {
321 val filtered = ArrayList<DiffRow>(rows.size)
322 var currentFileCollapsed = false
323 rows.forEach { row ->
324 if (row.kind == "file") {
325 currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId)
326 filtered.add(row)
327 } else if (!currentFileCollapsed) {
328 if (row.kind != "comment" || !collapsedCommentIds.contains(row.id)) {
329 filtered.add(row)
330 } else {
331 filtered.add(row.copy(commentText = "Comment collapsed"))
332 }
333 }
334 }
335 visibleRows = filtered
336 canvasView.rows = filtered
337 canvasView.viewedFileIds = viewedFileIds
338 canvasView.selectedRowIds = selectedRowIds
339 applyPendingInitialScroll()
340 }
341
342 private fun handleRowTap(row: DiffRow, gesture: String) {
343 when (row.kind) {
344 "file" -> {
345 if (gesture == "longPress") {
346 onToggleViewedFile(mapOf("fileId" to row.resolvedFileId))
347 } else {
348 onToggleFile(mapOf("fileId" to row.resolvedFileId))
349 }
350 }
351 "comment" -> onToggleComment(mapOf("commentId" to row.id))
352 "line" -> {
353 val payload = mutableMapOf<String, Any>(
354 "rowId" to row.id,
355 "fileId" to row.resolvedFileId,
356 "gesture" to gesture,
357 "change" to row.change,
358 )
359 row.oldLineNumber?.let { payload["oldLineNumber"] = it }
360 row.newLineNumber?.let { payload["newLineNumber"] = it }
361 onPressLine(payload)
362 }
363 }
364 }
365
366 @Suppress("ReturnCount")
367 private fun emitVisibleFile(firstVisibleIndex: Int) {
368 if (visibleRows.isEmpty()) return
369 val start = firstVisibleIndex.coerceIn(0, visibleRows.lastIndex)
370 val fileId = (start downTo 0)
371 .asSequence()
372 .map { visibleRows[it].resolvedFileId }
373 .firstOrNull { it.isNotEmpty() }
374 ?: return
375 if (fileId == lastVisibleFileId) return
376 lastVisibleFileId = fileId
377 onVisibleFileChange(mapOf("fileId" to fileId))
378 }
379
380 private fun applyPendingInitialScroll() {
381 if (!pendingInitialScroll || visibleRows.isEmpty()) return
382 pendingInitialScroll = false
383 val index = initialRowIndex.coerceIn(0, visibleRows.lastIndex)
384 post { canvasView.setVerticalOffset(canvasView.rowTop(index)) }
385 }
386
387 private fun scrollToY(y: Int, animated: Boolean) {
388 val target = y.coerceIn(0, canvasView.maxVerticalOffset())
389 if (animated) {
390 verticalScroller.startScroll(
391 0,
392 canvasView.verticalOffset(),
393 0,
394 target - canvasView.verticalOffset(),
395 250,
396 )
397 postInvalidateOnAnimation()
398 } else {
399 canvasView.setVerticalOffset(target)
400 }
401 }
402
403 private fun dp(value: Float): Float = value * resources.displayMetrics.density
404
405 private enum class DragAxis {
406 VERTICAL,
407 HORIZONTAL
408 }
409}
410
411private data class DiffRow(
412 val kind: String,
413 val id: String,
414 val fileId: String,
415 val filePath: String,
416 val previousPath: String?,
417 val changeType: String,
418 val additions: Int,
419 val deletions: Int,
420 val text: String,
421 val content: String,
422 val change: String,
423 val oldLineNumber: Int?,
424 val newLineNumber: Int?,
425 val commentText: String,
426 val commentRangeLabel: String,
427 val commentSectionTitle: String
428) {
429 val resolvedFileId: String get() = fileId.ifEmpty { id }
430}
431
432private data class DiffToken(
433 val content: String,
434 val color: Int?,
435 val fontStyle: Int
436)
437
438private data class DiffTheme(
439 val background: Int,
440 val text: Int,
441 val mutedText: Int,
442 val headerBackground: Int,
443 val border: Int,
444 val hunkBackground: Int,
445 val hunkText: Int,
446 val addBackground: Int,
447 val deleteBackground: Int,
448 val addBar: Int,
449 val deleteBar: Int,
450 val addText: Int,
451 val deleteText: Int
452) {
453 companion object {
454 fun fallback(scheme: String): DiffTheme = if (scheme == "dark") {
455 DiffTheme(
456 background = Color.rgb(20, 22, 25),
457 text = Color.rgb(236, 238, 240),
458 mutedText = Color.rgb(153, 160, 170),
459 headerBackground = Color.rgb(26, 29, 33),
460 border = Color.rgb(52, 57, 64),
461 hunkBackground = Color.rgb(7, 31, 40),
462 hunkText = Color.rgb(0, 159, 255),
463 addBackground = Color.rgb(13, 47, 40),
464 deleteBackground = Color.rgb(57, 20, 21),
465 addBar = Color.rgb(0, 202, 177),
466 deleteBar = Color.rgb(255, 46, 63),
467 addText = Color.rgb(94, 204, 113),
468 deleteText = Color.rgb(255, 103, 98),
469 )
470 } else {
471 DiffTheme(
472 background = Color.WHITE,
473 text = Color.rgb(7, 7, 7),
474 mutedText = Color.rgb(102, 106, 115),
475 headerBackground = Color.WHITE,
476 border = Color.rgb(222, 224, 228),
477 hunkBackground = Color.rgb(224, 242, 255),
478 hunkText = Color.rgb(0, 130, 220),
479 addBackground = Color.rgb(229, 248, 245),
480 deleteBackground = Color.rgb(255, 230, 231),
481 addBar = Color.rgb(0, 172, 151),
482 deleteBar = Color.rgb(213, 44, 54),
483 addText = Color.rgb(25, 130, 67),
484 deleteText = Color.rgb(190, 38, 48),
485 )
486 }
487
488 fun fromJson(value: String, fallback: DiffTheme): DiffTheme = try {
489 val json = JSONObject(value)
490 DiffTheme(
491 background = color(json, "background", fallback.background),
492 text = color(json, "text", fallback.text),
493 mutedText = color(json, "mutedText", fallback.mutedText),
494 headerBackground = color(json, "headerBackground", fallback.headerBackground),
495 border = color(json, "border", fallback.border),
496 hunkBackground = color(json, "hunkBackground", fallback.hunkBackground),
497 hunkText = color(json, "hunkText", fallback.hunkText),
498 addBackground = color(json, "addBackground", fallback.addBackground),
499 deleteBackground = color(json, "deleteBackground", fallback.deleteBackground),
500 addBar = color(json, "addBar", fallback.addBar),
501 deleteBar = color(json, "deleteBar", fallback.deleteBar),
502 addText = color(json, "addText", fallback.addText),
503 deleteText = color(json, "deleteText", fallback.deleteText),
504 )
505 } catch (_: Exception) {
506 fallback
507 }
508
509 private fun color(json: JSONObject, key: String, fallback: Int): Int =
510 parseColor(json.optString(key), fallback)
511 }
512}
513
514private data class DiffStyle(
515 val rowHeightPx: Float,
516 val gutterWidthPx: Float,
517 val codePaddingPx: Float,
518 val changeBarWidthPx: Float,
519 val fileHeaderHeightPx: Float,
520 val codeFontSizePx: Float,
521 val lineNumberFontSizePx: Float
522) {
523 companion object {
524 fun defaults(density: Float): DiffStyle = DiffStyle(
525 rowHeightPx = 20f * density,
526 gutterWidthPx = 72f * density,
527 codePaddingPx = 10f * density,
528 changeBarWidthPx = 3f * density,
529 fileHeaderHeightPx = 44f * density,
530 codeFontSizePx = 12f * density,
531 lineNumberFontSizePx = 10f * density,
532 )
533
534 fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try {
535 val json = JSONObject(value)
536 DiffStyle(
537 rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density),
538 gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density),
539 codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density),
540 changeBarWidthPx = json.floatDp("changeBarWidth", fallback.changeBarWidthPx, density),
541 fileHeaderHeightPx = json.floatDp("fileHeaderHeight", fallback.fileHeaderHeightPx, density),
542 codeFontSizePx = json.floatSp("codeFontSize", fallback.codeFontSizePx, density),
543 lineNumberFontSizePx = json.floatSp(
544 "lineNumberFontSize",
545 fallback.lineNumberFontSizePx,
546 density
547 ),
548 )
549 } catch (_: Exception) {
550 fallback
551 }
552 }
553}
554
555private class DiffCanvasView(context: Context) : View(context) {
556 private val density = resources.displayMetrics.density
557 private val backgroundPaint = Paint()
558 private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG)
559 private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
560 private val boldTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
561 typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
562 }
563 private val gestureDetector = GestureDetector(
564 context,
565 object : GestureDetector.SimpleOnGestureListener() {
566 override fun onDown(event: MotionEvent): Boolean = true
567
568 override fun onSingleTapUp(event: MotionEvent): Boolean {
569 rowAt(event.y)?.let { onRowTap?.invoke(it, "tap") }
570 return true
571 }
572
573 override fun onLongPress(event: MotionEvent) {
574 rowAt(event.y)?.let { onRowTap?.invoke(it, "longPress") }
575 }
576 },
577 )
578 private var rowOffsets = intArrayOf(0)
579 private var verticalOffset = 0
580 private var horizontalOffset = 0
581 private var lastVisibleRange: Pair<Int, Int>? = null
582
583 var rows: List<DiffRow> = emptyList()
584 set(value) {
585 field = value
586 rebuildOffsets()
587 }
588 var tokensByRowId: Map<String, List<DiffToken>> = emptyMap()
589 set(value) {
590 field = value
591 invalidate()
592 }
593 var viewedFileIds: Set<String> = emptySet()
594 set(value) {
595 field = value
596 invalidate()
597 }
598 var selectedRowIds: Set<String> = emptySet()
599 set(value) {
600 field = value
601 invalidate()
602 }
603 var theme: DiffTheme = DiffTheme.fallback("light")
604 set(value) {
605 field = value
606 invalidate()
607 }
608 var style: DiffStyle = DiffStyle.defaults(density)
609 set(value) {
610 field = value
611 rebuildOffsets()
612 }
613 var contentWidthPx: Int = (1200 * density).toInt()
614 set(value) {
615 field = max(value, suggestedMinimumWidth)
616 setHorizontalOffset(horizontalOffset)
617 invalidate()
618 }
619 var onRowTap: ((DiffRow, String) -> Unit)? = null
620 var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null
621
622 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
623 setMeasuredDimension(
624 MeasureSpec.getSize(widthMeasureSpec),
625 MeasureSpec.getSize(heightMeasureSpec),
626 )
627 }
628
629 override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
630 super.onSizeChanged(width, height, oldWidth, oldHeight)
631 setVerticalOffset(verticalOffset)
632 setHorizontalOffset(horizontalOffset)
633 }
634
635 override fun onDraw(canvas: Canvas) {
636 super.onDraw(canvas)
637 canvas.drawColor(theme.background)
638 if (rows.isEmpty()) return
639 val first = rowIndexAt(max(0, verticalOffset + canvas.clipBounds.top))
640 val last = rowIndexAt(
641 min(max(0, rowOffsets.last() - 1), verticalOffset + canvas.clipBounds.bottom),
642 ).coerceAtLeast(first)
643 for (index in first..last.coerceAtMost(rows.lastIndex)) {
644 drawRow(
645 canvas,
646 rows[index],
647 rowOffsets[index] - verticalOffset,
648 rowOffsets[index + 1] - verticalOffset,
649 )
650 }
651 drawStickyFileHeader(canvas, first)
652 drawHorizontalScrollIndicator(canvas)
653 emitVisibleRange()
654 }
655
656 override fun onTouchEvent(event: MotionEvent): Boolean = gestureDetector.onTouchEvent(event)
657
658 fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))]
659
660 fun setVerticalOffset(value: Int) {
661 val maxOffset = max(0, (rowOffsets.lastOrNull() ?: 0) - height)
662 val nextOffset = value.coerceIn(0, maxOffset)
663 if (verticalOffset == nextOffset) return
664 verticalOffset = nextOffset
665 invalidate()
666 emitVisibleRange()
667 }
668
669 fun scrollByVertical(delta: Int) {
670 setVerticalOffset(verticalOffset + delta)
671 }
672
673 fun verticalOffset(): Int = verticalOffset
674
675 fun maxVerticalOffset(): Int = max(0, (rowOffsets.lastOrNull() ?: 0) - height)
676
677 fun setHorizontalOffset(value: Int) {
678 val nextOffset = value.coerceIn(0, maxHorizontalOffset())
679 if (horizontalOffset == nextOffset) return
680 horizontalOffset = nextOffset
681 invalidate()
682 }
683
684 fun scrollByHorizontal(delta: Int) {
685 setHorizontalOffset(horizontalOffset + delta)
686 }
687
688 fun horizontalOffset(): Int = horizontalOffset
689
690 fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width)
691
692 private fun rebuildOffsets() {
693 rowOffsets = IntArray(rows.size + 1)
694 rows.forEachIndexed { index, row ->
695 rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row)
696 }
697 setVerticalOffset(verticalOffset)
698 requestLayout()
699 invalidate()
700 }
701
702 private fun rowHeight(row: DiffRow): Int = when (row.kind) {
703 "file" -> style.fileHeaderHeightPx.toInt()
704 "comment" -> max((style.rowHeightPx * 3.2f).toInt(), (56 * density).toInt())
705 else -> style.rowHeightPx.toInt()
706 }.coerceAtLeast(1)
707
708 @Suppress("ReturnCount")
709 private fun rowIndexAt(y: Int): Int {
710 if (rows.isEmpty()) return 0
711 var low = 0
712 var high = rows.lastIndex
713 while (low <= high) {
714 val middle = (low + high) ushr 1
715 when {
716 y < rowOffsets[middle] -> high = middle - 1
717 y >= rowOffsets[middle + 1] -> low = middle + 1
718 else -> return middle
719 }
720 }
721 return low.coerceIn(0, rows.lastIndex)
722 }
723
724 private fun rowAt(y: Float): DiffRow? {
725 stickyFileHeader(firstVisibleRow())?.let { sticky ->
726 if (y >= max(0, sticky.top).toFloat() && y < sticky.bottom.toFloat()) {
727 return rows.getOrNull(sticky.index)
728 }
729 }
730 return rows.getOrNull(rowIndexAt(verticalOffset + y.toInt()))
731 }
732
733 private fun firstVisibleRow(): Int = rowIndexAt(verticalOffset)
734
735 private fun emitVisibleRange() {
736 if (rows.isEmpty()) return
737 val first = rowIndexAt(verticalOffset)
738 val last = rowIndexAt(verticalOffset + max(1, height))
739 val range = first to last
740 if (range == lastVisibleRange) return
741 lastVisibleRange = range
742 onVisibleRowsChanged?.invoke(first, last)
743 }
744
745 private fun drawRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
746 when (row.kind) {
747 "file" -> drawFileRow(canvas, row, top, bottom)
748 "hunk" -> drawHunkRow(canvas, row, top, bottom)
749 "notice" -> drawNoticeRow(canvas, row, top, bottom)
750 "comment" -> drawCommentRow(canvas, row, top, bottom)
751 else -> drawLineRow(canvas, row, top, bottom)
752 }
753 }
754
755 private fun drawFileRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
756 fill(canvas, theme.headerBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
757 val baseline = centeredBaseline(top, bottom, boldTextPaint.apply { textSize = 13f * density })
758 boldTextPaint.color = theme.text
759 val marker = if (viewedFileIds.contains(row.resolvedFileId)) "[x] " else "[ ] "
760 textPaint.color = theme.mutedText
761 textPaint.textSize = 11f * density
762 val stats = "+${row.additions} -${row.deletions}"
763 val statsX = max(12f * density, width - textPaint.measureText(stats) - 16f * density)
764 val title = ellipsize(marker + row.filePath, boldTextPaint, statsX - 28f * density)
765 canvas.drawText(title, 12f * density, baseline, boldTextPaint)
766 canvas.drawText(stats, statsX, baseline, textPaint)
767 drawBottomBorder(canvas, bottom)
768 }
769
770 private fun drawHunkRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
771 fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
772 textPaint.color = theme.hunkText
773 textPaint.textSize = style.codeFontSizePx
774 drawScrollableCode(canvas, top, bottom) { codeX ->
775 canvas.drawText(
776 row.text.ifEmpty { row.content },
777 codeX,
778 centeredBaseline(top, bottom, textPaint),
779 textPaint,
780 )
781 }
782 }
783
784 private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
785 textPaint.color = theme.mutedText
786 textPaint.textSize = style.codeFontSizePx
787 drawScrollableCode(canvas, top, bottom) { codeX ->
788 canvas.drawText(row.text, codeX, centeredBaseline(top, bottom, textPaint), textPaint)
789 }
790 }
791
792 private fun drawCommentRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
793 fill(
794 canvas,
795 theme.headerBackground,
796 style.gutterWidthPx,
797 top.toFloat(),
798 width.toFloat(),
799 bottom.toFloat()
800 )
801 boldTextPaint.color = theme.text
802 boldTextPaint.textSize = 12f * density
803 drawScrollableCode(canvas, top, bottom) { codeX ->
804 canvas.drawText(
805 row.commentSectionTitle.ifEmpty { row.commentRangeLabel.ifEmpty { "Comment" } },
806 codeX,
807 top + 20f * density,
808 boldTextPaint,
809 )
810 textPaint.color = theme.mutedText
811 textPaint.textSize = 12f * density
812 canvas.drawText(row.commentText, codeX, top + 42f * density, textPaint)
813 }
814 drawBottomBorder(canvas, bottom)
815 }
816
817 @Suppress("CyclomaticComplexMethod")
818 private fun drawLineRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
819 val background = when (row.change) {
820 "add" -> theme.addBackground
821 "delete" -> theme.deleteBackground
822 else -> theme.background
823 }
824 fill(canvas, background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
825 val selected = selectedRowIds.contains(row.id)
826 if (selected) {
827 fill(
828 canvas,
829 withAlpha(theme.hunkText, if (theme.background == Color.WHITE) 54 else 76),
830 0f,
831 top.toFloat(),
832 width.toFloat(),
833 bottom.toFloat(),
834 )
835 }
836 val barColor = when (row.change) {
837 "add" -> theme.addBar
838 "delete" -> theme.deleteBar
839 else -> Color.TRANSPARENT
840 }
841 if (barColor != Color.TRANSPARENT && style.changeBarWidthPx > 0) {
842 fill(canvas, barColor, 0f, top.toFloat(), style.changeBarWidthPx, bottom.toFloat())
843 }
844
845 val tokens = tokensByRowId[row.id]
846 drawScrollableCode(canvas, top, bottom) { codeX ->
847 if (tokens.isNullOrEmpty()) {
848 textPaint.textSize = style.codeFontSizePx
849 textPaint.color = when (row.change) {
850 "add" -> theme.addText
851 "delete" -> theme.deleteText
852 else -> theme.text
853 }
854 canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint)
855 } else {
856 var x = codeX
857 tokens.forEach { token ->
858 textPaint.textSize = style.codeFontSizePx
859 textPaint.color = token.color ?: theme.text
860 textPaint.typeface = when {
861 token.fontStyle and 1 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.ITALIC)
862 token.fontStyle and 2 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
863 else -> Typeface.MONOSPACE
864 }
865 canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint)
866 x += textPaint.measureText(token.content)
867 }
868 textPaint.typeface = Typeface.MONOSPACE
869 }
870 }
871
872 textPaint.textSize = style.lineNumberFontSizePx
873 textPaint.color = if (selected) theme.text else theme.mutedText
874 val oldNumber = row.oldLineNumber?.toString().orEmpty()
875 val newNumber = row.newLineNumber?.toString().orEmpty()
876 val baseline = centeredBaseline(top, bottom, textPaint)
877 canvas.drawText(oldNumber, style.changeBarWidthPx + 6f * density, baseline, textPaint)
878 canvas.drawText(
879 newNumber,
880 style.changeBarWidthPx + style.gutterWidthPx / 2f,
881 baseline,
882 textPaint
883 )
884 }
885
886 private fun drawScrollableCode(
887 canvas: Canvas,
888 top: Int,
889 bottom: Int,
890 draw: (Float) -> Unit
891 ) {
892 val gutterEnd = style.changeBarWidthPx + style.gutterWidthPx
893 canvas.save()
894 canvas.clipRect(gutterEnd, top.toFloat(), width.toFloat(), bottom.toFloat())
895 draw(gutterEnd + style.codePaddingPx - horizontalOffset)
896 canvas.restore()
897 }
898
899 private fun drawStickyFileHeader(canvas: Canvas, firstVisibleIndex: Int) {
900 val sticky = stickyFileHeader(firstVisibleIndex) ?: return
901 val naturalTop = rowOffsets[sticky.index] - verticalOffset
902 if (naturalTop == sticky.top) return
903 drawFileRow(canvas, rows[sticky.index], sticky.top, sticky.bottom)
904 }
905
906 @Suppress("ReturnCount")
907 private fun stickyFileHeader(firstVisibleIndex: Int): StickyFileHeader? {
908 if (rows.isEmpty()) return null
909 val fileIndex = (firstVisibleIndex.coerceIn(0, rows.lastIndex) downTo 0)
910 .firstOrNull { rows[it].kind == "file" }
911 ?: return null
912 val headerHeight = rowHeight(rows[fileIndex])
913 val nextFileIndex = ((fileIndex + 1)..rows.lastIndex).firstOrNull { rows[it].kind == "file" }
914 val top = nextFileIndex
915 ?.let { min(0, rowOffsets[it] - verticalOffset - headerHeight) }
916 ?: 0
917 return StickyFileHeader(fileIndex, top, top + headerHeight)
918 }
919
920 private fun drawHorizontalScrollIndicator(canvas: Canvas) {
921 val maxOffset = maxHorizontalOffset()
922 if (maxOffset <= 0 || width <= 0) return
923 val trackWidth = width.toFloat()
924 val thumbWidth = max(24f * density, trackWidth * trackWidth / contentWidthPx)
925 val thumbTravel = trackWidth - thumbWidth
926 val left = thumbTravel * horizontalOffset / maxOffset
927 fill(
928 canvas,
929 withAlpha(theme.mutedText, 110),
930 left,
931 height - 2f * density,
932 left + thumbWidth,
933 height.toFloat(),
934 )
935 }
936
937 @Suppress("LongParameterList")
938 private fun fill(
939 canvas: Canvas,
940 color: Int,
941 left: Float,
942 top: Float,
943 right: Float,
944 bottom: Float
945 ) {
946 backgroundPaint.color = color
947 canvas.drawRect(left, top, right, bottom, backgroundPaint)
948 }
949
950 private fun drawBottomBorder(canvas: Canvas, bottom: Int) {
951 borderPaint.color = theme.border
952 borderPaint.strokeWidth = density
953 canvas.drawLine(0f, bottom - density / 2f, width.toFloat(), bottom - density / 2f, borderPaint)
954 }
955
956 private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float {
957 val metrics = paint.fontMetrics
958 return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f
959 }
960
961 private fun ellipsize(value: String, paint: Paint, width: Float): String {
962 if (paint.measureText(value) <= width) return value
963 val suffix = "..."
964 val available = max(0f, width - paint.measureText(suffix))
965 var end = value.length
966 while (end > 0 && paint.measureText(value, 0, end) > available) end -= 1
967 return value.substring(0, end) + suffix
968 }
969
970 private data class StickyFileHeader(
971 val index: Int,
972 val top: Int,
973 val bottom: Int
974 )
975}
976
977private fun withAlpha(color: Int, alpha: Int): Int =
978 Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))
979
980private fun parseRows(value: String): List<DiffRow> = try {
981 val array = JSONArray(value)
982 List(array.length()) { index ->
983 val row = array.getJSONObject(index)
984 DiffRow(
985 kind = row.optString("kind"),
986 id = row.optString("id"),
987 fileId = row.optString("fileId"),
988 filePath = row.optString("filePath"),
989 previousPath = row.optNullableString("previousPath"),
990 changeType = row.optString("changeType"),
991 additions = row.optInt("additions"),
992 deletions = row.optInt("deletions"),
993 text = row.optString("text"),
994 content = row.optString("content"),
995 change = row.optString("change", "context"),
996 oldLineNumber = row.optNullableInt("oldLineNumber"),
997 newLineNumber = row.optNullableInt("newLineNumber"),
998 commentText = row.optString("commentText"),
999 commentRangeLabel = row.optString("commentRangeLabel"),
1000 commentSectionTitle = row.optString("commentSectionTitle"),
1001 )
1002 }
1003} catch (_: Exception) {
1004 emptyList()
1005}
1006
1007private fun parseTokensObject(value: String): Map<String, List<DiffToken>> = try {
1008 parseTokensObject(JSONObject(value))
1009} catch (_: Exception) {
1010 emptyMap()
1011}
1012
1013private fun parseTokensObject(value: JSONObject): Map<String, List<DiffToken>> {
1014 val result = LinkedHashMap<String, List<DiffToken>>()
1015 val keys = value.keys()
1016 while (keys.hasNext()) {
1017 val rowId = keys.next()
1018 val array = value.optJSONArray(rowId) ?: continue
1019 result[rowId] = List(array.length()) { index ->
1020 val token = array.getJSONObject(index)
1021 DiffToken(
1022 content = token.optString("content"),
1023 color = token.optNullableString("color")?.let { parseColor(it, Color.TRANSPARENT) },
1024 fontStyle = token.optInt("fontStyle"),
1025 )
1026 }
1027 }
1028 return result
1029}
1030
1031private fun parseStringSet(value: String): Set<String> = try {
1032 val array = JSONArray(value)
1033 buildSet {
1034 for (index in 0 until array.length()) add(array.getString(index))
1035 }
1036} catch (_: Exception) {
1037 emptySet()
1038}
1039
1040private fun parseColor(value: String, fallback: Int): Int = try {
1041 Color.parseColor(value)
1042} catch (_: Exception) {
1043 fallback
1044}
1045
1046private fun JSONObject.optNullableString(key: String): String? =
1047 if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
1048
1049private fun JSONObject.optNullableInt(key: String): Int? =
1050 if (isNull(key) || !has(key)) null else optInt(key)
1051
1052private fun JSONObject.floatDp(key: String, fallbackPx: Float, density: Float): Float =
1053 if (has(key)) optDouble(key).toFloat() * density else fallbackPx
1054
1055private fun JSONObject.floatSp(key: String, fallbackPx: Float, density: Float): Float =
1056 if (has(key)) optDouble(key).toFloat() * density else fallbackPx
apps/mobile/modules/t3-review-diff/expo-module.config.json+4-1
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1{1{
2 "platforms": ["apple"],2 "platforms": ["apple", "android"],
3 "apple": {3 "apple": {
4 "modules": ["T3ReviewDiffModule"],4 "modules": ["T3ReviewDiffModule"],
5 "podspecPath": "T3ReviewDiffNative.podspec"5 "podspecPath": "T3ReviewDiffNative.podspec"
6 },
7 "android": {
8 "modules": ["expo.modules.t3reviewdiff.T3ReviewDiffModule"]
6 }9 }
7}10}
apps/mobile/modules/t3-review-diff/package.json+5
...@@ -7,6 +7,11 @@...@@ -7,6 +7,11 @@
7 "modules": [7 "modules": [
8 "T3ReviewDiffModule"8 "T3ReviewDiffModule"
9 ]9 ]
10 },
11 "android": {
12 "modules": [
13 "expo.modules.t3reviewdiff.T3ReviewDiffModule"
14 ]
10 }15 }
11 }16 }
12}17}
apps/mobile/modules/t3-terminal/README.md+15-3
...@@ -18,9 +18,9 @@ uses that callback I/O model:...@@ -18,9 +18,9 @@ uses that callback I/O model:
184. send user input back to JS with the write callback184. send user input back to JS with the write callback
195. emit Ghostty's measured terminal size through `onResize`195. emit Ghostty's measured terminal size through `onResize`
2020
21Android currently implements the same view name (`T3TerminalSurface`) and event payloads so the21Android implements the same view contract with upstream `libghostty-vt` for terminal state, parsing,
22React Native screen and RPC code stay platform-neutral. The renderer backend can be replaced with a22reflow, and scrollback. An Android Canvas view renders compact snapshots produced by the JNI bridge,
23future Android Ghostty build without changing JS.23so the React Native screen and RPC code stay platform-neutral.
2424
25Vendored Ghostty revision and license details are in `THIRD_PARTY_NOTICES.md`.25Vendored Ghostty revision and license details are in `THIRD_PARTY_NOTICES.md`.
2626
...@@ -36,3 +36,15 @@ apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh...@@ -36,3 +36,15 @@ apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh
36The script builds Ghostty with Zig 0.15.2, strips the iOS archives, and replaces only the36The script builds Ghostty with Zig 0.15.2, strips the iOS archives, and replaces only the
37`ios-arm64` and `ios-arm64-simulator` slices. Xcode's Metal toolchain must be installed; if `metal`37`ios-arm64` and `ios-arm64-simulator` slices. Xcode's Metal toolchain must be installed; if `metal`
38fails, run `xcodebuild -downloadComponent MetalToolchain`.38fails, run `xcodebuild -downloadComponent MetalToolchain`.
39
40## Rebuilding libghostty-vt for Android
41
42The checked-in Android shared libraries and headers are pinned to the revision recorded in
43`Vendor/libghostty-vt/VERSION`. Set `ANDROID_NDK_HOME` and run:
44
45```bash
46apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh
47```
48
49The script downloads Zig 0.15.2 when needed, checks out the pinned upstream Ghostty revision, and
50rebuilds all four Android ABIs with 16 KB page-size support.
apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md+18
...@@ -14,3 +14,21 @@ iOS 16 support fork. That fork was created from VVTerm's custom-I/O Ghostty fork...@@ -14,3 +14,21 @@ iOS 16 support fork. That fork was created from VVTerm's custom-I/O Ghostty fork
1414
15Ghostty's MIT license applies to the vendored framework. Keep this notice in sync when updating15Ghostty's MIT license applies to the vendored framework. Keep this notice in sync when updating
16`Vendor/libghostty`.16`Vendor/libghostty`.
17
18## Ghostty / libghostty-vt
19
20The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers.
21
22- Upstream project: https://github.com/ghostty-org/ghostty
23- Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697`
24- License: MIT
25
26Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when
27updating `Vendor/libghostty-vt`.
28
29## MesloLGS NF (Android terminal font)
30
31- Files: `android/src/main/assets/fonts/MesloLGS-NF-{Regular,Bold}.ttf`
32- Source: https://github.com/romkatv/powerlevel10k-media (Meslo LG patched with Nerd Fonts glyphs)
33- Upstream: Meslo LG by André Berg (customization of Apple's Menlo), Nerd Fonts patcher
34- License: Apache License 2.0
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/LICENSE created+21
...@@ -0,0 +1,21 @@
1MIT License
2
3Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors
4
5Permission is hereby granted, free of charge, to any person obtaining a copy
6of this software and associated documentation files (the "Software"), to deal
7in the Software without restriction, including without limitation the rights
8to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9copies of the Software, and to permit persons to whom the Software is
10furnished to do so, subject to the following conditions:
11
12The above copyright notice and this permission notice shall be included in all
13copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21SOFTWARE.
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION created+1
...@@ -0,0 +1 @@
19f62873bf195e4d8a762d768a1405a5f2f7b1697
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt.h created+154
...@@ -0,0 +1,154 @@
1/**
2 * @file vt.h
3 *
4 * libghostty-vt - Virtual terminal emulator library
5 *
6 * This library provides functionality for parsing and handling terminal
7 * escape sequences as well as maintaining terminal state such as styles,
8 * cursor position, screen, scrollback, and more.
9 *
10 * WARNING: This is an incomplete, work-in-progress API. It is not yet
11 * stable and is definitely going to change.
12 */
13
14/**
15 * @mainpage libghostty-vt - Virtual Terminal Emulator Library
16 *
17 * libghostty-vt is a C library which implements a modern terminal emulator,
18 * extracted from the [Ghostty](https://ghostty.org) terminal emulator.
19 *
20 * libghostty-vt contains the logic for handling the core parts of a terminal
21 * emulator: parsing terminal escape sequences, maintaining terminal state,
22 * encoding input events, etc. It can handle scrollback, line wrapping,
23 * reflow on resize, and more.
24 *
25 * @warning This library is currently in development and the API is not yet stable.
26 * Breaking changes are expected in future versions. Use with caution in production code.
27 *
28 * @section groups_sec API Reference
29 *
30 * The API is organized into the following groups:
31 * - @ref terminal "Terminal" - Complete terminal emulator state and rendering
32 * - @ref render "Render State" - Incremental render state updates for custom renderers
33 * - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML
34 * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences
35 * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences
36 * - @ref paste "Paste Utilities" - Validate paste data safety
37 * - @ref build_info "Build Info" - Query compile-time build configuration
38 * - @ref allocator "Memory Management" - Memory management and custom allocators
39 * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions
40 *
41 * Encoding related APIs:
42 * - @ref focus "Focus Encoding" - Encode focus in/out events into terminal sequences
43 * - @ref key "Key Encoding" - Encode key events into terminal sequences
44 * - @ref mouse "Mouse Encoding" - Encode mouse events into terminal sequences
45 *
46 * @section examples_sec Examples
47 *
48 * Complete working examples:
49 * - @ref c-vt-build-info/src/main.c - Build info query example
50 * - @ref c-vt/src/main.c - OSC parser example
51 * - @ref c-vt-encode-key/src/main.c - Key encoding example
52 * - @ref c-vt-encode-mouse/src/main.c - Mouse encoding example
53 * - @ref c-vt-paste/src/main.c - Paste safety check example
54 * - @ref c-vt-sgr/src/main.c - SGR parser example
55 * - @ref c-vt-formatter/src/main.c - Terminal formatter example
56 * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs
57 * - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example
58 *
59 */
60
61/** @example c-vt-build-info/src/main.c
62 * This example demonstrates how to query compile-time build configuration
63 * such as SIMD support, Kitty graphics, and tmux control mode availability.
64 */
65
66/** @example c-vt/src/main.c
67 * This example demonstrates how to use the OSC parser to parse an OSC sequence,
68 * extract command information, and retrieve command-specific data like window titles.
69 */
70
71/** @example c-vt-encode-key/src/main.c
72 * This example demonstrates how to use the key encoder to convert key events
73 * into terminal escape sequences using the Kitty keyboard protocol.
74 */
75
76/** @example c-vt-encode-mouse/src/main.c
77 * This example demonstrates how to use the mouse encoder to convert mouse events
78 * into terminal escape sequences using the SGR mouse format.
79 */
80
81/** @example c-vt-paste/src/main.c
82 * This example demonstrates how to use the paste utilities to check if
83 * paste data is safe before sending it to the terminal.
84 */
85
86/** @example c-vt-sgr/src/main.c
87 * This example demonstrates how to use the SGR parser to parse terminal
88 * styling sequences and extract text attributes like colors and underline styles.
89 */
90
91/** @example c-vt-formatter/src/main.c
92 * This example demonstrates how to use the terminal and formatter APIs to
93 * create a terminal, write VT-encoded content into it, and format the screen
94 * contents as plain text.
95 */
96
97/** @example c-vt-grid-traverse/src/main.c
98 * This example demonstrates how to traverse the entire terminal grid using
99 * grid refs to inspect cell codepoints, row wrap state, and cell styles.
100 */
101
102/** @example c-vt-grid-ref-tracked/src/main.c
103 * This example demonstrates how to track a grid ref as the terminal scrolls,
104 * detect when it loses its value, and move it to a new point.
105 */
106
107/** @example c-vt-selection-gesture/src/main.c
108 * This example demonstrates how to use synthetic selection gesture events to
109 * derive drag and deep-press selection snapshots.
110 */
111
112/** @example c-vt-kitty-graphics/src/main.c
113 * This example demonstrates how to use the system interface to install a
114 * PNG decoder callback and send a Kitty Graphics Protocol image.
115 */
116
117#ifndef GHOSTTY_VT_H
118#define GHOSTTY_VT_H
119
120#ifdef __cplusplus
121extern "C" {
122#endif
123
124#include <ghostty/vt/types.h>
125#include <ghostty/vt/allocator.h>
126#include <ghostty/vt/build_info.h>
127#include <ghostty/vt/color.h>
128#include <ghostty/vt/device.h>
129#include <ghostty/vt/focus.h>
130#include <ghostty/vt/formatter.h>
131#include <ghostty/vt/render.h>
132#include <ghostty/vt/terminal.h>
133#include <ghostty/vt/grid_ref.h>
134#include <ghostty/vt/grid_ref_tracked.h>
135#include <ghostty/vt/osc.h>
136#include <ghostty/vt/sgr.h>
137#include <ghostty/vt/style.h>
138#include <ghostty/vt/sys.h>
139#include <ghostty/vt/key.h>
140#include <ghostty/vt/kitty_graphics.h>
141#include <ghostty/vt/modes.h>
142#include <ghostty/vt/mouse.h>
143#include <ghostty/vt/paste.h>
144#include <ghostty/vt/point.h>
145#include <ghostty/vt/screen.h>
146#include <ghostty/vt/selection.h>
147#include <ghostty/vt/size_report.h>
148#include <ghostty/vt/wasm.h>
149
150#ifdef __cplusplus
151}
152#endif
153
154#endif /* GHOSTTY_VT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/allocator.h created+255
...@@ -0,0 +1,255 @@
1/**
2 * @file allocator.h
3 *
4 * Memory management interface for libghostty-vt.
5 */
6
7#ifndef GHOSTTY_VT_ALLOCATOR_H
8#define GHOSTTY_VT_ALLOCATOR_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/types.h>
14
15/** @defgroup allocator Memory Management
16 *
17 * libghostty-vt does require memory allocation for various operations,
18 * but is resilient to allocation failures and will gracefully handle
19 * out-of-memory situations by returning error codes.
20 *
21 * The exact memory management semantics are documented in the relevant
22 * functions and data structures.
23 *
24 * libghostty-vt uses explicit memory allocation via an allocator
25 * interface provided by GhosttyAllocator. The interface is based on the
26 * [Zig](https://ziglang.org) allocator interface, since this has been
27 * shown to be a flexible and powerful interface in practice and enables
28 * a wide variety of allocation strategies.
29 *
30 * **For the common case, you can pass NULL as the allocator for any
31 * function that accepts one,** and libghostty will use a default allocator.
32 * The default allocator will be libc malloc/free if libc is linked.
33 * Otherwise, a custom allocator is used (currently Zig's SMP allocator)
34 * that doesn't require any external dependencies.
35 *
36 * ## Basic Usage
37 *
38 * For simple use cases, you can ignore this interface entirely by passing NULL
39 * as the allocator parameter to functions that accept one. This will use the
40 * default allocator (typically libc malloc/free, if libc is linked, but
41 * we provide our own default allocator if libc isn't linked).
42 *
43 * To use a custom allocator:
44 * 1. Implement the GhosttyAllocatorVtable function pointers
45 * 2. Create a GhosttyAllocator struct with your vtable and context
46 * 3. Pass the allocator to functions that accept one
47 *
48 * ## Alloc/Free Helpers
49 *
50 * ghostty_alloc() and ghostty_free() provide a simple malloc/free-style
51 * interface for allocating and freeing byte buffers through the library's
52 * allocator. These are useful when:
53 *
54 * - You need to allocate a buffer to pass into a libghostty-vt function
55 * (e.g. preparing input data for ghostty_terminal_vt_write()).
56 * - You need to free a buffer returned by a libghostty-vt function
57 * (e.g. the output of ghostty_formatter_format_alloc()).
58 * - You are on a platform where the library's internal allocator differs
59 * from the consumer's C runtime (e.g. Windows, where Zig's libc and
60 * MSVC's CRT maintain separate heaps), so calling the standard C
61 * free() on library-allocated memory would be undefined behavior.
62 *
63 * Always use the same allocator (or NULL) for both the allocation and
64 * the corresponding free.
65 *
66 * @{
67 */
68
69/**
70 * Function table for custom memory allocator operations.
71 *
72 * This vtable defines the interface for a custom memory allocator. All
73 * function pointers must be valid and non-NULL.
74 *
75 * @ingroup allocator
76 *
77 * If you're not going to use a custom allocator, you can ignore all of
78 * this. All functions that take an allocator pointer allow NULL to use a
79 * default allocator.
80 *
81 * The interface is based on the Zig allocator interface. I'll say up front
82 * that it is easy to look at this interface and think "wow, this is really
83 * overcomplicated". The reason for this complexity is well thought out by
84 * the Zig folks, and it enables a diverse set of allocation strategies
85 * as shown by the Zig ecosystem. As a consolation, please note that many
86 * of the arguments are only needed for advanced use cases and can be
87 * safely ignored in simple implementations. For example, if you look at
88 * the Zig implementation of the libc allocator in `lib/std/heap.zig`
89 * (search for CAllocator), you'll see it is very simple.
90 *
91 * We chose to align with the Zig allocator interface because:
92 *
93 * 1. It is a proven interface that serves a wide variety of use cases
94 * in the real world via the Zig ecosystem. It's shown to work.
95 *
96 * 2. Our core implementation itself is Zig, and this lets us very
97 * cheaply and easily convert between C and Zig allocators.
98 *
99 * NOTE(mitchellh): In the future, we can have default implementations of
100 * resize/remap and allow those to be null.
101 */
102typedef struct {
103 /**
104 * Return a pointer to `len` bytes with specified `alignment`, or return
105 * `NULL` indicating the allocation failed.
106 *
107 * @param ctx The allocator context
108 * @param len Number of bytes to allocate
109 * @param alignment Required alignment for the allocation. Guaranteed to
110 * be a power of two between 1 and 16 inclusive.
111 * @param ret_addr First return address of the allocation call stack (0 if not provided)
112 * @return Pointer to allocated memory, or NULL if allocation failed
113 */
114 void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr);
115
116 /**
117 * Attempt to expand or shrink memory in place.
118 *
119 * `memory_len` must equal the length requested from the most recent
120 * successful call to `alloc`, `resize`, or `remap`. `alignment` must
121 * equal the same value that was passed as the `alignment` parameter to
122 * the original `alloc` call.
123 *
124 * `new_len` must be greater than zero.
125 *
126 * @param ctx The allocator context
127 * @param memory Pointer to the memory block to resize
128 * @param memory_len Current size of the memory block
129 * @param alignment Alignment (must match original allocation)
130 * @param new_len New requested size
131 * @param ret_addr First return address of the allocation call stack (0 if not provided)
132 * @return true if resize was successful in-place, false if relocation would be required
133 */
134 bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);
135
136 /**
137 * Attempt to expand or shrink memory, allowing relocation.
138 *
139 * `memory_len` must equal the length requested from the most recent
140 * successful call to `alloc`, `resize`, or `remap`. `alignment` must
141 * equal the same value that was passed as the `alignment` parameter to
142 * the original `alloc` call.
143 *
144 * A non-`NULL` return value indicates the resize was successful. The
145 * allocation may have same address, or may have been relocated. In either
146 * case, the allocation now has size of `new_len`. A `NULL` return value
147 * indicates that the resize would be equivalent to allocating new memory,
148 * copying the bytes from the old memory, and then freeing the old memory.
149 * In such case, it is more efficient for the caller to perform the copy.
150 *
151 * `new_len` must be greater than zero.
152 *
153 * @param ctx The allocator context
154 * @param memory Pointer to the memory block to remap
155 * @param memory_len Current size of the memory block
156 * @param alignment Alignment (must match original allocation)
157 * @param new_len New requested size
158 * @param ret_addr First return address of the allocation call stack (0 if not provided)
159 * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed
160 */
161 void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);
162
163 /**
164 * Free and invalidate a region of memory.
165 *
166 * `memory_len` must equal the length requested from the most recent
167 * successful call to `alloc`, `resize`, or `remap`. `alignment` must
168 * equal the same value that was passed as the `alignment` parameter to
169 * the original `alloc` call.
170 *
171 * @param ctx The allocator context
172 * @param memory Pointer to the memory block to free
173 * @param memory_len Size of the memory block
174 * @param alignment Alignment (must match original allocation)
175 * @param ret_addr First return address of the allocation call stack (0 if not provided)
176 */
177 void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr);
178} GhosttyAllocatorVtable;
179
180/**
181 * Custom memory allocator.
182 *
183 * For functions that take an allocator pointer, a NULL pointer indicates
184 * that the default allocator should be used. The default allocator will
185 * be libc malloc/free if we're linking to libc. If libc isn't linked,
186 * a custom allocator is used (currently Zig's SMP allocator).
187 *
188 * @ingroup allocator
189 *
190 * Usage example:
191 * @code
192 * GhosttyAllocator allocator = {
193 * .vtable = &my_allocator_vtable,
194 * .ctx = my_allocator_state
195 * };
196 * @endcode
197 */
198typedef struct GhosttyAllocator {
199 /**
200 * Opaque context pointer passed to all vtable functions.
201 * This allows the allocator implementation to maintain state
202 * or reference external resources needed for memory management.
203 */
204 void *ctx;
205
206 /**
207 * Pointer to the allocator's vtable containing function pointers
208 * for memory operations (alloc, resize, remap, free).
209 */
210 const GhosttyAllocatorVtable *vtable;
211} GhosttyAllocator;
212
213/**
214 * Allocate a buffer of `len` bytes.
215 *
216 * Uses the provided allocator, or the default allocator if NULL is passed.
217 * The returned buffer must be freed with ghostty_free() using the same
218 * allocator.
219 *
220 * @param allocator Pointer to the allocator to use, or NULL for the default
221 * @param len Number of bytes to allocate
222 * @return Pointer to the allocated buffer, or NULL if allocation failed
223 *
224 * @ingroup allocator
225 */
226GHOSTTY_API uint8_t* ghostty_alloc(const GhosttyAllocator* allocator, size_t len);
227
228/**
229 * Free memory that was allocated by a libghostty-vt function.
230 *
231 * Use this to free buffers returned by functions such as
232 * ghostty_formatter_format_alloc(). Pass the same allocator that was
233 * used for the allocation, or NULL if the default allocator was used.
234 *
235 * On platforms where the library's internal allocator differs from the
236 * consumer's C runtime (e.g. Windows, where Zig's libc and MSVC's CRT
237 * maintain separate heaps), calling the standard C free() on memory
238 * allocated by the library causes undefined behavior. This function
239 * guarantees the correct allocator is used regardless of platform.
240 *
241 * It is safe to pass a NULL pointer; the call is a no-op in that case.
242 *
243 * @param allocator Pointer to the allocator that was used to allocate the
244 * memory, or NULL if the default allocator was used
245 * @param ptr Pointer to the memory to free (may be NULL)
246 * @param len Length of the allocation in bytes (must match the original
247 * allocation size)
248 *
249 * @ingroup allocator
250 */
251GHOSTTY_API void ghostty_free(const GhosttyAllocator* allocator, uint8_t* ptr, size_t len);
252
253/** @} */
254
255#endif /* GHOSTTY_VT_ALLOCATOR_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/build_info.h created+150
...@@ -0,0 +1,150 @@
1/**
2 * @file build_info.h
3 *
4 * Build info - query compile-time build configuration of libghostty-vt.
5 */
6
7#ifndef GHOSTTY_VT_BUILD_INFO_H
8#define GHOSTTY_VT_BUILD_INFO_H
9
10/** @defgroup build_info Build Info
11 *
12 * Query compile-time build configuration of libghostty-vt.
13 *
14 * These values reflect the options the library was built with and are
15 * constant for the lifetime of the process.
16 *
17 * ## Basic Usage
18 *
19 * Use ghostty_build_info() to query individual build options:
20 *
21 * @snippet c-vt-build-info/src/main.c build-info-query
22 *
23 * @{
24 */
25
26#include <stddef.h>
27#include <stdbool.h>
28
29#include <ghostty/vt/types.h>
30
31#ifdef __cplusplus
32extern "C" {
33#endif
34
35/**
36 * Build optimization mode.
37 */
38typedef enum GHOSTTY_ENUM_TYPED {
39 GHOSTTY_OPTIMIZE_DEBUG = 0,
40 GHOSTTY_OPTIMIZE_RELEASE_SAFE = 1,
41 GHOSTTY_OPTIMIZE_RELEASE_SMALL = 2,
42 GHOSTTY_OPTIMIZE_RELEASE_FAST = 3,
43 GHOSTTY_OPTIMIZE_MODE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
44} GhosttyOptimizeMode;
45
46/**
47 * Build info data types that can be queried.
48 *
49 * Each variant documents the expected output pointer type.
50 */
51typedef enum GHOSTTY_ENUM_TYPED {
52 /** Invalid data type. Never results in any data extraction. */
53 GHOSTTY_BUILD_INFO_INVALID = 0,
54
55 /**
56 * Whether SIMD-accelerated code paths are enabled.
57 *
58 * Output type: bool *
59 */
60 GHOSTTY_BUILD_INFO_SIMD = 1,
61
62 /**
63 * Whether Kitty graphics protocol support is available.
64 *
65 * Output type: bool *
66 */
67 GHOSTTY_BUILD_INFO_KITTY_GRAPHICS = 2,
68
69 /**
70 * Whether tmux control mode support is available.
71 *
72 * Output type: bool *
73 */
74 GHOSTTY_BUILD_INFO_TMUX_CONTROL_MODE = 3,
75
76 /**
77 * The optimization mode the library was built with.
78 *
79 * Output type: GhosttyOptimizeMode *
80 */
81 GHOSTTY_BUILD_INFO_OPTIMIZE = 4,
82
83 /**
84 * The full version string (e.g. "1.2.3" or "1.2.3-dev+abcdef").
85 *
86 * Output type: GhosttyString *
87 */
88 GHOSTTY_BUILD_INFO_VERSION_STRING = 5,
89
90 /**
91 * The major version number.
92 *
93 * Output type: size_t *
94 */
95 GHOSTTY_BUILD_INFO_VERSION_MAJOR = 6,
96
97 /**
98 * The minor version number.
99 *
100 * Output type: size_t *
101 */
102 GHOSTTY_BUILD_INFO_VERSION_MINOR = 7,
103
104 /**
105 * The patch version number.
106 *
107 * Output type: size_t *
108 */
109 GHOSTTY_BUILD_INFO_VERSION_PATCH = 8,
110
111 /**
112 * The pre metadata string (e.g. "alpha", "beta", "dev"). Has zero length if
113 * no pre metadata is present.
114 *
115 * Output type: GhosttyString *
116 */
117 GHOSTTY_BUILD_INFO_VERSION_PRE = 9,
118
119 /**
120 * The build metadata string (e.g. commit hash). Has zero length if
121 * no build metadata is present.
122 *
123 * Output type: GhosttyString *
124 */
125 GHOSTTY_BUILD_INFO_VERSION_BUILD = 10,
126 GHOSTTY_BUILD_INFO_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
127} GhosttyBuildInfo;
128
129/**
130 * Query a compile-time build configuration value.
131 *
132 * The caller must pass a pointer to the correct output type for the
133 * requested data (see GhosttyBuildInfo variants for types).
134 *
135 * @param data The build info field to query
136 * @param out Pointer to store the result (type depends on data parameter)
137 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the
138 * data type is invalid
139 *
140 * @ingroup build_info
141 */
142GHOSTTY_API GhosttyResult ghostty_build_info(GhosttyBuildInfo data, void *out);
143
144#ifdef __cplusplus
145}
146#endif
147
148/** @} */
149
150#endif /* GHOSTTY_VT_BUILD_INFO_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/color.h created+97
...@@ -0,0 +1,97 @@
1/**
2 * @file color.h
3 *
4 * Color types and utilities.
5 */
6
7#ifndef GHOSTTY_VT_COLOR_H
8#define GHOSTTY_VT_COLOR_H
9
10#include <stdint.h>
11#include <ghostty/vt/types.h>
12
13#ifdef __cplusplus
14extern "C" {
15#endif
16
17/**
18 * RGB color value.
19 *
20 * @ingroup sgr
21 */
22typedef struct {
23 uint8_t r; /**< Red component (0-255) */
24 uint8_t g; /**< Green component (0-255) */
25 uint8_t b; /**< Blue component (0-255) */
26} GhosttyColorRgb;
27
28/**
29 * Palette color index (0-255).
30 *
31 * @ingroup sgr
32 */
33typedef uint8_t GhosttyColorPaletteIndex;
34
35/** @addtogroup sgr
36 * @{
37 */
38
39/** Black color (0) @ingroup sgr */
40#define GHOSTTY_COLOR_NAMED_BLACK 0
41/** Red color (1) @ingroup sgr */
42#define GHOSTTY_COLOR_NAMED_RED 1
43/** Green color (2) @ingroup sgr */
44#define GHOSTTY_COLOR_NAMED_GREEN 2
45/** Yellow color (3) @ingroup sgr */
46#define GHOSTTY_COLOR_NAMED_YELLOW 3
47/** Blue color (4) @ingroup sgr */
48#define GHOSTTY_COLOR_NAMED_BLUE 4
49/** Magenta color (5) @ingroup sgr */
50#define GHOSTTY_COLOR_NAMED_MAGENTA 5
51/** Cyan color (6) @ingroup sgr */
52#define GHOSTTY_COLOR_NAMED_CYAN 6
53/** White color (7) @ingroup sgr */
54#define GHOSTTY_COLOR_NAMED_WHITE 7
55/** Bright black color (8) @ingroup sgr */
56#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8
57/** Bright red color (9) @ingroup sgr */
58#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9
59/** Bright green color (10) @ingroup sgr */
60#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10
61/** Bright yellow color (11) @ingroup sgr */
62#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11
63/** Bright blue color (12) @ingroup sgr */
64#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12
65/** Bright magenta color (13) @ingroup sgr */
66#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13
67/** Bright cyan color (14) @ingroup sgr */
68#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14
69/** Bright white color (15) @ingroup sgr */
70#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15
71
72/** @} */
73
74/**
75 * Get the RGB color components.
76 *
77 * This function extracts the individual red, green, and blue components
78 * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments
79 * where accessing struct fields directly is difficult.
80 *
81 * @param color The RGB color value
82 * @param r Pointer to store the red component (0-255)
83 * @param g Pointer to store the green component (0-255)
84 * @param b Pointer to store the blue component (0-255)
85 *
86 * @ingroup sgr
87 */
88GHOSTTY_API void ghostty_color_rgb_get(GhosttyColorRgb color,
89 uint8_t* r,
90 uint8_t* g,
91 uint8_t* b);
92
93#ifdef __cplusplus
94}
95#endif
96
97#endif /* GHOSTTY_VT_COLOR_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/device.h created+151
...@@ -0,0 +1,151 @@
1/**
2 * @file device.h
3 *
4 * Device types used by the terminal for device status and device attribute
5 * queries.
6 */
7
8#ifndef GHOSTTY_VT_DEVICE_H
9#define GHOSTTY_VT_DEVICE_H
10
11#include <stddef.h>
12#include <stdint.h>
13
14/* DA1 conformance levels (Pp parameter). */
15#define GHOSTTY_DA_CONFORMANCE_VT100 1
16#define GHOSTTY_DA_CONFORMANCE_VT101 1
17#define GHOSTTY_DA_CONFORMANCE_VT102 6
18#define GHOSTTY_DA_CONFORMANCE_VT125 12
19#define GHOSTTY_DA_CONFORMANCE_VT131 7
20#define GHOSTTY_DA_CONFORMANCE_VT132 4
21#define GHOSTTY_DA_CONFORMANCE_VT220 62
22#define GHOSTTY_DA_CONFORMANCE_VT240 62
23#define GHOSTTY_DA_CONFORMANCE_VT320 63
24#define GHOSTTY_DA_CONFORMANCE_VT340 63
25#define GHOSTTY_DA_CONFORMANCE_VT420 64
26#define GHOSTTY_DA_CONFORMANCE_VT510 65
27#define GHOSTTY_DA_CONFORMANCE_VT520 65
28#define GHOSTTY_DA_CONFORMANCE_VT525 65
29#define GHOSTTY_DA_CONFORMANCE_LEVEL_2 62
30#define GHOSTTY_DA_CONFORMANCE_LEVEL_3 63
31#define GHOSTTY_DA_CONFORMANCE_LEVEL_4 64
32#define GHOSTTY_DA_CONFORMANCE_LEVEL_5 65
33
34/* DA1 feature codes (Ps parameters). */
35#define GHOSTTY_DA_FEATURE_COLUMNS_132 1
36#define GHOSTTY_DA_FEATURE_PRINTER 2
37#define GHOSTTY_DA_FEATURE_REGIS 3
38#define GHOSTTY_DA_FEATURE_SIXEL 4
39#define GHOSTTY_DA_FEATURE_SELECTIVE_ERASE 6
40#define GHOSTTY_DA_FEATURE_USER_DEFINED_KEYS 8
41#define GHOSTTY_DA_FEATURE_NATIONAL_REPLACEMENT 9
42#define GHOSTTY_DA_FEATURE_TECHNICAL_CHARACTERS 15
43#define GHOSTTY_DA_FEATURE_LOCATOR 16
44#define GHOSTTY_DA_FEATURE_TERMINAL_STATE 17
45#define GHOSTTY_DA_FEATURE_WINDOWING 18
46#define GHOSTTY_DA_FEATURE_HORIZONTAL_SCROLLING 21
47#define GHOSTTY_DA_FEATURE_ANSI_COLOR 22
48#define GHOSTTY_DA_FEATURE_RECTANGULAR_EDITING 28
49#define GHOSTTY_DA_FEATURE_ANSI_TEXT_LOCATOR 29
50#define GHOSTTY_DA_FEATURE_CLIPBOARD 52
51
52/* DA2 device type identifiers (Pp parameter). */
53#define GHOSTTY_DA_DEVICE_TYPE_VT100 0
54#define GHOSTTY_DA_DEVICE_TYPE_VT220 1
55#define GHOSTTY_DA_DEVICE_TYPE_VT240 2
56#define GHOSTTY_DA_DEVICE_TYPE_VT330 18
57#define GHOSTTY_DA_DEVICE_TYPE_VT340 19
58#define GHOSTTY_DA_DEVICE_TYPE_VT320 24
59#define GHOSTTY_DA_DEVICE_TYPE_VT382 32
60#define GHOSTTY_DA_DEVICE_TYPE_VT420 41
61#define GHOSTTY_DA_DEVICE_TYPE_VT510 61
62#define GHOSTTY_DA_DEVICE_TYPE_VT520 64
63#define GHOSTTY_DA_DEVICE_TYPE_VT525 65
64
65#ifdef __cplusplus
66extern "C" {
67#endif
68
69/**
70 * Color scheme reported in response to a CSI ? 996 n query.
71 *
72 * @ingroup terminal
73 */
74typedef enum GHOSTTY_ENUM_TYPED {
75 GHOSTTY_COLOR_SCHEME_LIGHT = 0,
76 GHOSTTY_COLOR_SCHEME_DARK = 1,
77 GHOSTTY_COLOR_SCHEME_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
78} GhosttyColorScheme;
79
80/**
81 * Primary device attributes (DA1) response data.
82 *
83 * Returned as part of GhosttyDeviceAttributes in response to a CSI c query.
84 * The conformance_level is the Pp parameter and features contains the Ps
85 * feature codes.
86 *
87 * @ingroup terminal
88 */
89typedef struct {
90 /** Conformance level (Pp parameter). E.g. 62 for VT220. */
91 uint16_t conformance_level;
92
93 /** DA1 feature codes. Only the first num_features entries are valid. */
94 uint16_t features[64];
95
96 /** Number of valid entries in the features array. */
97 size_t num_features;
98} GhosttyDeviceAttributesPrimary;
99
100/**
101 * Secondary device attributes (DA2) response data.
102 *
103 * Returned as part of GhosttyDeviceAttributes in response to a CSI > c query.
104 * Response format: CSI > Pp ; Pv ; Pc c
105 *
106 * @ingroup terminal
107 */
108typedef struct {
109 /** Terminal type identifier (Pp). E.g. 1 for VT220. */
110 uint16_t device_type;
111
112 /** Firmware/patch version number (Pv). */
113 uint16_t firmware_version;
114
115 /** ROM cartridge registration number (Pc). Always 0 for emulators. */
116 uint16_t rom_cartridge;
117} GhosttyDeviceAttributesSecondary;
118
119/**
120 * Tertiary device attributes (DA3) response data.
121 *
122 * Returned as part of GhosttyDeviceAttributes in response to a CSI = c query.
123 * Response format: DCS ! | D...D ST (DECRPTUI).
124 *
125 * @ingroup terminal
126 */
127typedef struct {
128 /** Unit ID encoded as 8 uppercase hex digits in the response. */
129 uint32_t unit_id;
130} GhosttyDeviceAttributesTertiary;
131
132/**
133 * Device attributes response data for all three DA levels.
134 *
135 * Filled by the device_attributes callback in response to CSI c,
136 * CSI > c, or CSI = c queries. The terminal uses whichever sub-struct
137 * matches the request type.
138 *
139 * @ingroup terminal
140 */
141typedef struct {
142 GhosttyDeviceAttributesPrimary primary;
143 GhosttyDeviceAttributesSecondary secondary;
144 GhosttyDeviceAttributesTertiary tertiary;
145} GhosttyDeviceAttributes;
146
147#ifdef __cplusplus
148}
149#endif
150
151#endif /* GHOSTTY_VT_DEVICE_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/focus.h created+76
...@@ -0,0 +1,76 @@
1/**
2 * @file focus.h
3 *
4 * Focus encoding - encode focus in/out events into terminal escape sequences.
5 */
6
7#ifndef GHOSTTY_VT_FOCUS_H
8#define GHOSTTY_VT_FOCUS_H
9
10/** @defgroup focus Focus Encoding
11 *
12 * Utilities for encoding focus gained/lost events into terminal escape
13 * sequences (CSI I / CSI O) for focus reporting mode (mode 1004).
14 *
15 * ## Basic Usage
16 *
17 * Use ghostty_focus_encode() to encode a focus event into a caller-provided
18 * buffer. If the buffer is too small, the function returns
19 * GHOSTTY_OUT_OF_SPACE and sets the required size in the output parameter.
20 *
21 * ## Example
22 *
23 * @snippet c-vt-encode-focus/src/main.c focus-encode
24 *
25 * @{
26 */
27
28#include <stddef.h>
29#include <ghostty/vt/types.h>
30
31#ifdef __cplusplus
32extern "C" {
33#endif
34
35/**
36 * Focus event types for focus reporting mode (mode 1004).
37 */
38typedef enum GHOSTTY_ENUM_TYPED {
39 /** Terminal window gained focus */
40 GHOSTTY_FOCUS_GAINED = 0,
41 /** Terminal window lost focus */
42 GHOSTTY_FOCUS_LOST = 1,
43 GHOSTTY_FOCUS_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
44} GhosttyFocusEvent;
45
46/**
47 * Encode a focus event into a terminal escape sequence.
48 *
49 * Encodes a focus gained (CSI I) or focus lost (CSI O) report into the
50 * provided buffer.
51 *
52 * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE
53 * and writes the required buffer size to @p out_written. The caller can
54 * then retry with a sufficiently sized buffer.
55 *
56 * @param event The focus event to encode
57 * @param buf Output buffer to write the encoded sequence into (may be NULL)
58 * @param buf_len Size of the output buffer in bytes
59 * @param[out] out_written On success, the number of bytes written. On
60 * GHOSTTY_OUT_OF_SPACE, the required buffer size.
61 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer
62 * is too small
63 */
64GHOSTTY_API GhosttyResult ghostty_focus_encode(
65 GhosttyFocusEvent event,
66 char* buf,
67 size_t buf_len,
68 size_t* out_written);
69
70#ifdef __cplusplus
71}
72#endif
73
74/** @} */
75
76#endif /* GHOSTTY_VT_FOCUS_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/formatter.h created+207
...@@ -0,0 +1,207 @@
1/**
2 * @file formatter.h
3 *
4 * Format terminal content as plain text, VT sequences, or HTML.
5 */
6
7#ifndef GHOSTTY_VT_FORMATTER_H
8#define GHOSTTY_VT_FORMATTER_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/allocator.h>
14#include <ghostty/vt/selection.h>
15#include <ghostty/vt/types.h>
16#include <ghostty/vt/terminal.h>
17
18#ifdef __cplusplus
19extern "C" {
20#endif
21
22/** @defgroup formatter Formatter
23 *
24 * Format terminal content as plain text, VT sequences, or HTML.
25 *
26 * A formatter captures a reference to a terminal and formatting options.
27 * It can be used repeatedly to produce output that reflects the current
28 * terminal state at the time of each format call.
29 *
30 * The terminal must outlive the formatter.
31 *
32 * @{
33 */
34
35/**
36 * Extra screen state to include in styled output.
37 *
38 * @ingroup formatter
39 */
40typedef struct {
41 /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterScreenExtra). */
42 size_t size;
43
44 /** Emit cursor position using CUP (CSI H). */
45 bool cursor;
46
47 /** Emit current SGR style state based on the cursor's active style_id. */
48 bool style;
49
50 /** Emit current hyperlink state using OSC 8 sequences. */
51 bool hyperlink;
52
53 /** Emit character protection mode using DECSCA. */
54 bool protection;
55
56 /** Emit Kitty keyboard protocol state using CSI > u and CSI = sequences. */
57 bool kitty_keyboard;
58
59 /** Emit character set designations and invocations. */
60 bool charsets;
61} GhosttyFormatterScreenExtra;
62
63/**
64 * Extra terminal state to include in styled output.
65 *
66 * @ingroup formatter
67 */
68typedef struct {
69 /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterTerminalExtra). */
70 size_t size;
71
72 /** Emit the palette using OSC 4 sequences. */
73 bool palette;
74
75 /** Emit terminal modes that differ from their defaults using CSI h/l. */
76 bool modes;
77
78 /** Emit scrolling region state using DECSTBM and DECSLRM sequences. */
79 bool scrolling_region;
80
81 /** Emit tabstop positions by clearing all tabs and setting each one. */
82 bool tabstops;
83
84 /** Emit the present working directory using OSC 7. */
85 bool pwd;
86
87 /** Emit keyboard modes such as ModifyOtherKeys. */
88 bool keyboard;
89
90 /** Screen-level extras. */
91 GhosttyFormatterScreenExtra screen;
92} GhosttyFormatterTerminalExtra;
93
94/**
95 * Options for creating a terminal formatter.
96 *
97 * @ingroup formatter
98 */
99typedef struct {
100 /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterTerminalOptions). */
101 size_t size;
102
103 /** Output format to emit. */
104 GhosttyFormatterFormat emit;
105
106 /** Whether to unwrap soft-wrapped lines. */
107 bool unwrap;
108
109 /** Whether to trim trailing whitespace on non-blank lines. */
110 bool trim;
111
112 /** Extra terminal state to include in styled output. */
113 GhosttyFormatterTerminalExtra extra;
114
115 /** Optional selection to restrict output to a range.
116 * If NULL, the entire screen is formatted. */
117 const GhosttySelection *selection;
118} GhosttyFormatterTerminalOptions;
119
120/**
121 * Create a formatter for a terminal's active screen.
122 *
123 * The terminal must outlive the formatter. The formatter stores a borrowed
124 * reference to the terminal and reads its current state on each format call.
125 *
126 * @param allocator Pointer to allocator, or NULL to use the default allocator
127 * @param formatter Pointer to store the created formatter handle
128 * @param terminal The terminal to format (must not be NULL)
129 * @param options Formatting options
130 * @return GHOSTTY_SUCCESS on success, or an error code on failure
131 *
132 * @ingroup formatter
133 */
134GHOSTTY_API GhosttyResult ghostty_formatter_terminal_new(
135 const GhosttyAllocator* allocator,
136 GhosttyFormatter* formatter,
137 GhosttyTerminal terminal,
138 GhosttyFormatterTerminalOptions options);
139
140/**
141 * Run the formatter and produce output into the caller-provided buffer.
142 *
143 * Each call formats the current terminal state. Pass NULL for buf to
144 * query the required buffer size without writing any output; in that case
145 * out_written receives the required size and the return value is
146 * GHOSTTY_OUT_OF_SPACE.
147 *
148 * If the buffer is too small, returns GHOSTTY_OUT_OF_SPACE and sets
149 * out_written to the required size. The caller can then retry with a
150 * larger buffer.
151 *
152 * @param formatter The formatter handle (must not be NULL)
153 * @param buf Pointer to the output buffer, or NULL to query size
154 * @param buf_len Length of the output buffer in bytes
155 * @param out_written Pointer to receive the number of bytes written,
156 * or the required size on failure
157 * @return GHOSTTY_SUCCESS on success, or an error code on failure
158 *
159 * @ingroup formatter
160 */
161GHOSTTY_API GhosttyResult ghostty_formatter_format_buf(GhosttyFormatter formatter,
162 uint8_t* buf,
163 size_t buf_len,
164 size_t* out_written);
165
166/**
167 * Run the formatter and return an allocated buffer with the output.
168 *
169 * Each call formats the current terminal state. The buffer is allocated
170 * using the provided allocator (or the default allocator if NULL).
171 * The caller is responsible for freeing the returned buffer with
172 * ghostty_free(), passing the same allocator (or NULL for the default)
173 * that was used for the allocation.
174 *
175 * @param formatter The formatter handle (must not be NULL)
176 * @param allocator Pointer to allocator, or NULL to use the default allocator
177 * @param out_ptr Pointer to receive the allocated buffer
178 * @param out_len Pointer to receive the length of the output in bytes
179 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation
180 * failure
181 *
182 * @ingroup formatter
183 */
184GHOSTTY_API GhosttyResult ghostty_formatter_format_alloc(GhosttyFormatter formatter,
185 const GhosttyAllocator* allocator,
186 uint8_t** out_ptr,
187 size_t* out_len);
188
189/**
190 * Free a formatter instance.
191 *
192 * Releases all resources associated with the formatter. After this call,
193 * the formatter handle becomes invalid.
194 *
195 * @param formatter The formatter handle to free (may be NULL)
196 *
197 * @ingroup formatter
198 */
199GHOSTTY_API void ghostty_formatter_free(GhosttyFormatter formatter);
200
201/** @} */
202
203#ifdef __cplusplus
204}
205#endif
206
207#endif /* GHOSTTY_VT_FORMATTER_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref.h created+212
...@@ -0,0 +1,212 @@
1/**
2 * @file grid_ref.h
3 *
4 * Terminal grid reference type for referencing a resolved position in the
5 * terminal grid.
6 */
7
8#ifndef GHOSTTY_VT_GRID_REF_H
9#define GHOSTTY_VT_GRID_REF_H
10
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/types.h>
14#include <ghostty/vt/screen.h>
15#include <ghostty/vt/style.h>
16
17#ifdef __cplusplus
18extern "C" {
19#endif
20
21/** @defgroup grid_ref Grid Reference
22 *
23 * A grid reference is a reference to a specific cell position in the
24 * terminal. Obtain a grid reference from `ghostty_terminal_grid_ref`
25 * for untracked or `ghostty_terminal_grid_ref_track` for tracked. Untracked
26 * vs tracked is explained next.
27 *
28 * Important: The grid reference APIs are not meant to be used as the core of a render
29 * loop. They are not built to sustain the framerates needed for rendering large
30 * screens. Use the render state API for that.
31 *
32 * ## Untracked vs Tracked References
33 *
34 * ### Untracked Reference
35 *
36 * An untracked grid reference is a value type that snapshots a specific
37 * cell. It is only valid until the next update to the terminal instance.
38 * There is no guarantee that it will remain valid after any operation,
39 * even if a seemingly unrelated part of the grid is changed. These are meant
40 * to be read and have their values cached immediately after obtaining it.
41 *
42 * An untracked grid reference has a performance cost in its initial lookup,
43 * but doesn't affect the ongoing performance of the terminal in any way,
44 * since it is a one-time snapshot.
45 *
46 * ### Tracked Reference
47 *
48 * A tracked grid reference follows its cell across normal screen operations.
49 * For example scrolling, scrollback pruning, resize/reflow, and other
50 * terminal mutations update the tracked reference automatically.
51 *
52 * A tracked reference can still lose its original semantic location. This can
53 * happen when the underlying grid is reset, pruned, or otherwise discarded in a
54 * way that cannot be mapped to a meaningful new cell. In that state,
55 * ghostty_tracked_grid_ref_has_value() returns false and
56 * ghostty_tracked_grid_ref_snapshot() / ghostty_tracked_grid_ref_point() return
57 * GHOSTTY_NO_VALUE. The handle remains valid, and callers may move it to a new
58 * point with ghostty_tracked_grid_ref_set().
59 *
60 * To read cell data from a tracked reference, first snapshot it with
61 * ghostty_tracked_grid_ref_snapshot(). The returned `GhosttyGridRef` is again
62 * an untracked reference and follows the same short lifetime rules as any other
63 * untracked grid reference.
64 *
65 * A tracked reference belongs to the terminal screen/page-list that was active
66 * when it was created or last set. Converting it to a point uses that owning
67 * screen/page-list, even if the terminal has since switched between primary and
68 * alternate screens. Calling ghostty_tracked_grid_ref_set() resolves the new
69 * point against the terminal's currently active screen/page-list and may move
70 * the tracked reference between screens.
71 *
72 * Tracked references are owned by the caller and must be freed with
73 * ghostty_tracked_grid_ref_free(). If the terminal that created a tracked
74 * reference is freed first, the handle remains valid only for tracked-grid-ref
75 * APIs: it reports no value and can still be freed.
76 *
77 * Each tracked reference adds bookkeeping to terminal mutations. Use them
78 * sparingly for long-lived anchors such as selections, search state, marks,
79 * or application-side bookmarks.
80 *
81 * ## Lifetime
82 *
83 * An untracked reference is a snapshot. It doesn't need to be freed.
84 * The safety of accessing the value is documented explicitly above: it
85 * is only safe to access any data until the next terminal mutating
86 * operation (including free).
87 *
88 * A tracked reference is allocated and must be freed when it is no
89 * longer needed. A tracked reference may outlive the terminal that created it;
90 * after terminal free, it reports no value and can still be freed.
91 *
92 * ## Examples
93 *
94 * @snippet c-vt-grid-traverse/src/main.c grid-ref-traverse
95 * @snippet c-vt-grid-ref-tracked/src/main.c grid-ref-tracked
96 *
97 * @{
98 */
99
100/**
101 * A resolved reference to a terminal cell position.
102 *
103 * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
104 *
105 * @ingroup grid_ref
106 */
107typedef struct {
108 size_t size;
109 void *node;
110 uint16_t x;
111 uint16_t y;
112} GhosttyGridRef;
113
114/**
115 * Get the cell from a grid reference.
116 *
117 * @param ref Pointer to the grid reference
118 * @param[out] out_cell On success, set to the cell at the ref's position (may be NULL)
119 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's
120 * node is NULL
121 *
122 * @ingroup grid_ref
123 */
124GHOSTTY_API GhosttyResult ghostty_grid_ref_cell(const GhosttyGridRef *ref,
125 GhosttyCell *out_cell);
126
127/**
128 * Get the row from a grid reference.
129 *
130 * @param ref Pointer to the grid reference
131 * @param[out] out_row On success, set to the row at the ref's position (may be NULL)
132 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's
133 * node is NULL
134 *
135 * @ingroup grid_ref
136 */
137GHOSTTY_API GhosttyResult ghostty_grid_ref_row(const GhosttyGridRef *ref,
138 GhosttyRow *out_row);
139
140/**
141 * Get the grapheme cluster codepoints for the cell at the grid reference's
142 * position.
143 *
144 * Writes the full grapheme cluster (the cell's primary codepoint followed by
145 * any combining codepoints) into the provided buffer. If the cell has no text,
146 * out_len is set to 0 and GHOSTTY_SUCCESS is returned.
147 *
148 * If the buffer is too small (or NULL), the function returns
149 * GHOSTTY_OUT_OF_SPACE and writes the required number of codepoints to
150 * out_len. The caller can then retry with a sufficiently sized buffer.
151 *
152 * @param ref Pointer to the grid reference
153 * @param buf Output buffer of uint32_t codepoints (may be NULL)
154 * @param buf_len Number of uint32_t elements in the buffer
155 * @param[out] out_len On success, the number of codepoints written. On
156 * GHOSTTY_OUT_OF_SPACE, the required buffer size in codepoints.
157 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's
158 * node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small
159 *
160 * @ingroup grid_ref
161 */
162GHOSTTY_API GhosttyResult ghostty_grid_ref_graphemes(const GhosttyGridRef *ref,
163 uint32_t *buf,
164 size_t buf_len,
165 size_t *out_len);
166
167/**
168 * Get the hyperlink URI for the cell at the grid reference's position.
169 *
170 * Writes the URI bytes into the provided buffer. If the cell has no
171 * hyperlink, out_len is set to 0 and GHOSTTY_SUCCESS is returned.
172 *
173 * If the buffer is too small (or NULL), the function returns
174 * GHOSTTY_OUT_OF_SPACE and writes the required number of bytes to
175 * out_len. The caller can then retry with a sufficiently sized buffer.
176 *
177 * @param ref Pointer to the grid reference
178 * @param buf Output buffer for the URI bytes (may be NULL)
179 * @param buf_len Size of the output buffer in bytes
180 * @param[out] out_len On success, the number of bytes written. On
181 * GHOSTTY_OUT_OF_SPACE, the required buffer size in bytes.
182 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's
183 * node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small
184 *
185 * @ingroup grid_ref
186 */
187GHOSTTY_API GhosttyResult ghostty_grid_ref_hyperlink_uri(
188 const GhosttyGridRef *ref,
189 uint8_t *buf,
190 size_t buf_len,
191 size_t *out_len);
192
193/**
194 * Get the style of the cell at the grid reference's position.
195 *
196 * @param ref Pointer to the grid reference
197 * @param[out] out_style On success, set to the cell's style (may be NULL)
198 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's
199 * node is NULL
200 *
201 * @ingroup grid_ref
202 */
203GHOSTTY_API GhosttyResult ghostty_grid_ref_style(const GhosttyGridRef *ref,
204 GhosttyStyle *out_style);
205
206/** @} */
207
208#ifdef __cplusplus
209}
210#endif
211
212#endif /* GHOSTTY_VT_GRID_REF_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h created+139
...@@ -0,0 +1,139 @@
1/**
2 * @file grid_ref_tracked.h
3 *
4 * Tracked terminal grid references.
5 */
6
7#ifndef GHOSTTY_VT_GRID_REF_TRACKED_H
8#define GHOSTTY_VT_GRID_REF_TRACKED_H
9
10#include <stdbool.h>
11#include <ghostty/vt/types.h>
12#include <ghostty/vt/grid_ref.h>
13#include <ghostty/vt/point.h>
14
15#ifdef __cplusplus
16extern "C" {
17#endif
18
19/**
20 * Tracked grid references are owned grid references that move with the
21 * terminal. See @ref grid_ref for the full overview of tracked and untracked
22 * grid reference behavior.
23 *
24 * @ingroup grid_ref
25 */
26
27/**
28 * Free a tracked grid reference.
29 *
30 * Passing NULL is allowed and has no effect. A tracked reference may be freed
31 * after the terminal that created it is freed.
32 *
33 * @param ref Tracked grid reference to free.
34 *
35 * @ingroup grid_ref
36 */
37GHOSTTY_API void ghostty_tracked_grid_ref_free(GhosttyTrackedGridRef ref);
38
39/**
40 * Return whether a tracked grid reference currently has a meaningful value.
41 *
42 * If the terminal that created the tracked reference has been freed, this
43 * returns false.
44 *
45 * @param ref Tracked grid reference.
46 * @return true if the reference currently has a meaningful value.
47 *
48 * @ingroup grid_ref
49 */
50GHOSTTY_API bool ghostty_tracked_grid_ref_has_value(
51 GhosttyTrackedGridRef ref);
52
53/**
54 * Convert a tracked grid reference to a point in the requested coordinate
55 * space.
56 *
57 * This is the tracked equivalent of ghostty_terminal_point_from_grid_ref().
58 * Unlike snapshotting, this does not expose an intermediate untracked
59 * GhosttyGridRef.
60 *
61 * A tracked reference is resolved against the terminal screen/page-list that
62 * currently owns the reference. If the terminal has switched between primary
63 * and alternate screens since the reference was created or last set, this may
64 * be different from the terminal's currently active screen.
65 *
66 * If the tracked reference no longer has a meaningful value, this returns
67 * GHOSTTY_NO_VALUE. GHOSTTY_NO_VALUE is also returned when the reference cannot
68 * be represented in the requested coordinate space, including after the
69 * terminal that created the tracked reference has been freed.
70 *
71 * @param ref Tracked grid reference.
72 * @param tag Coordinate space to convert into.
73 * @param[out] out_point On success, receives the coordinate. May be NULL.
74 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid,
75 * or GHOSTTY_NO_VALUE if there is no representable value.
76 *
77 * @ingroup grid_ref
78 */
79GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_point(
80 GhosttyTrackedGridRef ref,
81 GhosttyPointTag tag,
82 GhosttyPointCoordinate *out_point);
83
84/**
85 * Move an existing tracked grid reference to a new terminal point.
86 *
87 * On success, the tracked reference begins tracking the new point and any prior
88 * "no value" state is cleared. On GHOSTTY_OUT_OF_MEMORY, the original tracked
89 * reference is left unchanged.
90 *
91 * The terminal must be the same terminal that created the tracked reference.
92 * The point is resolved against the terminal screen/page-list that is active at
93 * the time this function is called. If the terminal has switched between
94 * primary and alternate screens, this may move the tracked reference from one
95 * screen/page-list to the other.
96 *
97 * @param ref Tracked grid reference.
98 * @param terminal Terminal instance that owns the reference.
99 * @param point New point to track.
100 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref, terminal,
101 * or point is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails.
102 *
103 * @ingroup grid_ref
104 */
105GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_set(
106 GhosttyTrackedGridRef ref,
107 GhosttyTerminal terminal,
108 GhosttyPoint point);
109
110/**
111 * Snapshot a tracked grid reference into a regular GhosttyGridRef.
112 *
113 * The returned GhosttyGridRef is an untracked snapshot and has the same
114 * lifetime rules as ghostty_terminal_grid_ref(): it is only valid until the
115 * next terminal update. Snapshot immediately before calling
116 * ghostty_grid_ref_cell(), ghostty_grid_ref_row(),
117 * ghostty_grid_ref_graphemes(), ghostty_grid_ref_hyperlink_uri(), or
118 * ghostty_grid_ref_style().
119 *
120 * If the tracked reference no longer has a meaningful value, this returns
121 * GHOSTTY_NO_VALUE. This includes references whose owning terminal has been
122 * freed.
123 *
124 * @param ref Tracked grid reference.
125 * @param[out] out_ref On success, receives an untracked snapshot. May be NULL.
126 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid,
127 * or GHOSTTY_NO_VALUE if the tracked location was discarded.
128 *
129 * @ingroup grid_ref
130 */
131GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_snapshot(
132 GhosttyTrackedGridRef ref,
133 GhosttyGridRef *out_ref);
134
135#ifdef __cplusplus
136}
137#endif
138
139#endif /* GHOSTTY_VT_GRID_REF_TRACKED_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key.h created+73
...@@ -0,0 +1,73 @@
1/**
2 * @file key.h
3 *
4 * Key encoding module - encode key events into terminal escape sequences.
5 */
6
7#ifndef GHOSTTY_VT_KEY_H
8#define GHOSTTY_VT_KEY_H
9
10/** @defgroup key Key Encoding
11 *
12 * Utilities for encoding key events into terminal escape sequences,
13 * supporting both legacy encoding as well as Kitty Keyboard Protocol.
14 *
15 * ## Basic Usage
16 *
17 * 1. Create an encoder instance with ghostty_key_encoder_new()
18 * 2. Configure encoder options with ghostty_key_encoder_setopt()
19 * or ghostty_key_encoder_setopt_from_terminal() if you have a
20 * GhosttyTerminal.
21 * 3. For each key event:
22 * - Create a key event with ghostty_key_event_new()
23 * - Set event properties (action, key, modifiers, etc.)
24 * - Encode with ghostty_key_encoder_encode()
25 * - Free the event with ghostty_key_event_free()
26 * - Note: You can also reuse the same key event multiple times by
27 * changing its properties.
28 * 4. Free the encoder with ghostty_key_encoder_free() when done
29 *
30 * For a complete working example, see example/c-vt-encode-key in the
31 * repository.
32 *
33 * ## Example
34 *
35 * @snippet c-vt-encode-key/src/main.c key-encode
36 *
37 * ## Example: Encoding with Terminal State
38 *
39 * When you have a GhosttyTerminal, you can sync its modes (cursor key
40 * application, Kitty flags, etc.) into the encoder automatically:
41 *
42 * @code{.c}
43 * // Create a terminal and feed it some VT data that changes modes
44 * GhosttyTerminal terminal;
45 * ghostty_terminal_new(NULL, &terminal,
46 * (GhosttyTerminalOptions){.cols = 80, .rows = 24, .max_scrollback = 0});
47 *
48 * // Application might write data that enables Kitty keyboard protocol, etc.
49 * ghostty_terminal_vt_write(terminal, vt_data, vt_len);
50 *
51 * // Create an encoder and sync its options from the terminal
52 * GhosttyKeyEncoder encoder;
53 * ghostty_key_encoder_new(NULL, &encoder);
54 * ghostty_key_encoder_setopt_from_terminal(encoder, terminal);
55 *
56 * // Encode a key event using the terminal-derived options
57 * char buf[128];
58 * size_t written = 0;
59 * ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);
60 *
61 * ghostty_key_encoder_free(encoder);
62 * ghostty_terminal_free(terminal);
63 * @endcode
64 *
65 * @{
66 */
67
68#include <ghostty/vt/key/event.h>
69#include <ghostty/vt/key/encoder.h>
70
71/** @} */
72
73#endif /* GHOSTTY_VT_KEY_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/encoder.h created+255
...@@ -0,0 +1,255 @@
1/**
2 * @file encoder.h
3 *
4 * Key event encoding to terminal escape sequences.
5 */
6
7#ifndef GHOSTTY_VT_KEY_ENCODER_H
8#define GHOSTTY_VT_KEY_ENCODER_H
9
10#include <stddef.h>
11#include <stdint.h>
12#include <ghostty/vt/types.h>
13#include <ghostty/vt/allocator.h>
14#include <ghostty/vt/terminal.h>
15#include <ghostty/vt/key/event.h>
16
17/**
18 * Opaque handle to a key encoder instance.
19 *
20 * This handle represents a key encoder that converts key events into terminal
21 * escape sequences.
22 *
23 * @ingroup key
24 */
25typedef struct GhosttyKeyEncoderImpl *GhosttyKeyEncoder;
26
27/**
28 * Kitty keyboard protocol flags.
29 *
30 * Bitflags representing the various modes of the Kitty keyboard protocol.
31 * These can be combined using bitwise OR operations. Valid values all
32 * start with `GHOSTTY_KITTY_KEY_`.
33 *
34 * @ingroup key
35 */
36typedef uint8_t GhosttyKittyKeyFlags;
37
38/** Kitty keyboard protocol disabled (all flags off) */
39#define GHOSTTY_KITTY_KEY_DISABLED 0
40
41/** Disambiguate escape codes */
42#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0)
43
44/** Report key press and release events */
45#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1)
46
47/** Report alternate key codes */
48#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2)
49
50/** Report all key events including those normally handled by the terminal */
51#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3)
52
53/** Report associated text with key events */
54#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4)
55
56/** All Kitty keyboard protocol flags enabled */
57#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED)
58
59/**
60 * macOS option key behavior.
61 *
62 * Determines whether the "option" key on macOS is treated as "alt" or not.
63 * See the Ghostty `macos-option-as-alt` configuration option for more details.
64 *
65 * @ingroup key
66 */
67typedef enum GHOSTTY_ENUM_TYPED {
68 /** Option key is not treated as alt */
69 GHOSTTY_OPTION_AS_ALT_FALSE = 0,
70 /** Option key is treated as alt */
71 GHOSTTY_OPTION_AS_ALT_TRUE = 1,
72 /** Only left option key is treated as alt */
73 GHOSTTY_OPTION_AS_ALT_LEFT = 2,
74 /** Only right option key is treated as alt */
75 GHOSTTY_OPTION_AS_ALT_RIGHT = 3,
76 GHOSTTY_OPTION_AS_ALT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
77} GhosttyOptionAsAlt;
78
79/**
80 * Key encoder option identifiers.
81 *
82 * These values are used with ghostty_key_encoder_setopt() to configure
83 * the behavior of the key encoder.
84 *
85 * @ingroup key
86 */
87typedef enum GHOSTTY_ENUM_TYPED {
88 /** Terminal DEC mode 1: cursor key application mode (value: bool) */
89 GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0,
90
91 /** Terminal DEC mode 66: keypad key application mode (value: bool) */
92 GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1,
93
94 /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */
95 GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2,
96
97 /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */
98 GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3,
99
100 /** xterm modifyOtherKeys mode 2 (value: bool) */
101 GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4,
102
103 /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */
104 GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5,
105
106 /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */
107 GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6,
108
109 /** Backarrow key mode (value: bool)
110 * See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170
111 * If `false` (the default), `backspace` emits 0x7f
112 * If `true`, `backspace` emits 0x08
113 */
114 GHOSTTY_KEY_ENCODER_OPT_BACKARROW_KEY_MODE = 7,
115
116 GHOSTTY_KEY_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
117} GhosttyKeyEncoderOption;
118
119/**
120 * Create a new key encoder instance.
121 *
122 * Creates a new key encoder with default options. The encoder can be configured
123 * using ghostty_key_encoder_setopt() and must be freed using
124 * ghostty_key_encoder_free() when no longer needed.
125 *
126 * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
127 * @param encoder Pointer to store the created encoder handle
128 * @return GHOSTTY_SUCCESS on success, or an error code on failure
129 *
130 * @ingroup key
131 */
132GHOSTTY_API GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder);
133
134/**
135 * Free a key encoder instance.
136 *
137 * Releases all resources associated with the key encoder. After this call,
138 * the encoder handle becomes invalid and must not be used.
139 *
140 * @param encoder The encoder handle to free (may be NULL)
141 *
142 * @ingroup key
143 */
144GHOSTTY_API void ghostty_key_encoder_free(GhosttyKeyEncoder encoder);
145
146/**
147 * Set an option on the key encoder.
148 *
149 * Configures the behavior of the key encoder. Options control various aspects
150 * of encoding such as terminal modes (cursor key application mode, keypad mode),
151 * protocol selection (Kitty keyboard protocol flags), and platform-specific
152 * behaviors (macOS option-as-alt).
153 *
154 * If you are using a terminal instance, you can set the key encoding
155 * options based on the active terminal state (e.g. legacy vs Kitty mode
156 * and associated flags) with ghostty_key_encoder_setopt_from_terminal().
157 *
158 * A null pointer value does nothing. It does not reset the value to the
159 * default. The setopt call will do nothing.
160 *
161 * @param encoder The encoder handle, must not be NULL
162 * @param option The option to set
163 * @param value Pointer to the value to set (type depends on the option)
164 *
165 * @ingroup key
166 */
167GHOSTTY_API void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value);
168
169/**
170 * Set encoder options from a terminal's current state.
171 *
172 * Reads the terminal's current modes and flags and applies them to the
173 * encoder's options. This sets cursor key application mode, keypad mode,
174 * alt escape prefix, modifyOtherKeys state, and Kitty keyboard protocol
175 * flags from the terminal state.
176 *
177 * Note that the `macos_option_as_alt` option cannot be determined from
178 * terminal state and is reset to `GHOSTTY_OPTION_AS_ALT_FALSE` by this
179 * call. Use ghostty_key_encoder_setopt() to set it afterward if needed.
180 *
181 * @param encoder The encoder handle, must not be NULL
182 * @param terminal The terminal handle, must not be NULL
183 *
184 * @ingroup key
185 */
186GHOSTTY_API void ghostty_key_encoder_setopt_from_terminal(GhosttyKeyEncoder encoder, GhosttyTerminal terminal);
187
188/**
189 * Encode a key event into a terminal escape sequence.
190 *
191 * Converts a key event into the appropriate terminal escape sequence based on
192 * the encoder's current options. The sequence is written to the provided buffer.
193 *
194 * Not all key events produce output. For example, unmodified modifier keys
195 * typically don't generate escape sequences. Check the out_len parameter to
196 * determine if any data was written.
197 *
198 * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_SPACE
199 * and out_len will contain the required buffer size. The caller can then
200 * allocate a larger buffer and call the function again.
201 *
202 * @param encoder The encoder handle, must not be NULL
203 * @param event The key event to encode, must not be NULL
204 * @param out_buf Buffer to write the encoded sequence to
205 * @param out_buf_size Size of the output buffer in bytes
206 * @param out_len Pointer to store the number of bytes written (may be NULL)
207 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer too small, or other error code
208 *
209 * ## Example: Calculate required buffer size
210 *
211 * @code{.c}
212 * // Query the required size with a NULL buffer (always returns OUT_OF_SPACE)
213 * size_t required = 0;
214 * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required);
215 * assert(result == GHOSTTY_OUT_OF_SPACE);
216 *
217 * // Allocate buffer of required size
218 * char *buf = malloc(required);
219 *
220 * // Encode with properly sized buffer
221 * size_t written = 0;
222 * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written);
223 * assert(result == GHOSTTY_SUCCESS);
224 *
225 * // Use the encoded sequence...
226 *
227 * free(buf);
228 * @endcode
229 *
230 * ## Example: Direct encoding with static buffer
231 *
232 * @code{.c}
233 * // Most escape sequences are short, so a static buffer often suffices
234 * char buf[128];
235 * size_t written = 0;
236 * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);
237 *
238 * if (result == GHOSTTY_SUCCESS) {
239 * // Write the encoded sequence to the terminal
240 * write(pty_fd, buf, written);
241 * } else if (result == GHOSTTY_OUT_OF_SPACE) {
242 * // Buffer too small, written contains required size
243 * char *dynamic_buf = malloc(written);
244 * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written);
245 * assert(result == GHOSTTY_SUCCESS);
246 * write(pty_fd, dynamic_buf, written);
247 * free(dynamic_buf);
248 * }
249 * @endcode
250 *
251 * @ingroup key
252 */
253GHOSTTY_API GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len);
254
255#endif /* GHOSTTY_VT_KEY_ENCODER_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/event.h created+482
...@@ -0,0 +1,482 @@
1/**
2 * @file event.h
3 *
4 * Key event representation and manipulation.
5 */
6
7#ifndef GHOSTTY_VT_KEY_EVENT_H
8#define GHOSTTY_VT_KEY_EVENT_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/types.h>
14#include <ghostty/vt/allocator.h>
15
16/**
17 * Opaque handle to a key event.
18 *
19 * This handle represents a keyboard input event containing information about
20 * the physical key pressed, modifiers, and generated text.
21 *
22 * @ingroup key
23 */
24typedef struct GhosttyKeyEventImpl *GhosttyKeyEvent;
25
26/**
27 * Keyboard input event types.
28 *
29 * @ingroup key
30 */
31typedef enum GHOSTTY_ENUM_TYPED {
32 /** Key was released */
33 GHOSTTY_KEY_ACTION_RELEASE = 0,
34 /** Key was pressed */
35 GHOSTTY_KEY_ACTION_PRESS = 1,
36 /** Key is being repeated (held down) */
37 GHOSTTY_KEY_ACTION_REPEAT = 2,
38 GHOSTTY_KEY_ACTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
39} GhosttyKeyAction;
40
41/**
42 * Keyboard modifier keys bitmask.
43 *
44 * A bitmask representing all keyboard modifiers. This tracks which modifier keys
45 * are pressed and, where supported by the platform, which side (left or right)
46 * of each modifier is active.
47 *
48 * Use the GHOSTTY_MODS_* constants to test and set individual modifiers.
49 *
50 * Modifier side bits are only meaningful when the corresponding modifier bit is set.
51 * Not all platforms support distinguishing between left and right modifier
52 * keys and Ghostty is built to expect that some platforms may not provide this
53 * information.
54 *
55 * @ingroup key
56 */
57typedef uint16_t GhosttyMods;
58
59/** Shift key is pressed */
60#define GHOSTTY_MODS_SHIFT (1 << 0)
61/** Control key is pressed */
62#define GHOSTTY_MODS_CTRL (1 << 1)
63/** Alt/Option key is pressed */
64#define GHOSTTY_MODS_ALT (1 << 2)
65/** Super/Command/Windows key is pressed */
66#define GHOSTTY_MODS_SUPER (1 << 3)
67/** Caps Lock is active */
68#define GHOSTTY_MODS_CAPS_LOCK (1 << 4)
69/** Num Lock is active */
70#define GHOSTTY_MODS_NUM_LOCK (1 << 5)
71
72/**
73 * Right shift is pressed (0 = left, 1 = right).
74 * Only meaningful when GHOSTTY_MODS_SHIFT is set.
75 */
76#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6)
77/**
78 * Right ctrl is pressed (0 = left, 1 = right).
79 * Only meaningful when GHOSTTY_MODS_CTRL is set.
80 */
81#define GHOSTTY_MODS_CTRL_SIDE (1 << 7)
82/**
83 * Right alt is pressed (0 = left, 1 = right).
84 * Only meaningful when GHOSTTY_MODS_ALT is set.
85 */
86#define GHOSTTY_MODS_ALT_SIDE (1 << 8)
87/**
88 * Right super is pressed (0 = left, 1 = right).
89 * Only meaningful when GHOSTTY_MODS_SUPER is set.
90 */
91#define GHOSTTY_MODS_SUPER_SIDE (1 << 9)
92
93/**
94 * Physical key codes.
95 *
96 * The set of key codes that Ghostty is aware of. These represent physical keys
97 * on the keyboard and are layout-independent. For example, the "a" key on a US
98 * keyboard is the same as the "ф" key on a Russian keyboard, but both will
99 * report the same key_a value.
100 *
101 * Layout-dependent strings are provided separately as UTF-8 text and are produced
102 * by the platform. These values are based on the W3C UI Events KeyboardEvent code
103 * standard. See: https://www.w3.org/TR/uievents-code
104 *
105 * @ingroup key
106 */
107typedef enum GHOSTTY_ENUM_TYPED {
108 GHOSTTY_KEY_UNIDENTIFIED = 0,
109
110 // Writing System Keys (W3C § 3.1.1)
111 GHOSTTY_KEY_BACKQUOTE,
112 GHOSTTY_KEY_BACKSLASH,
113 GHOSTTY_KEY_BRACKET_LEFT,
114 GHOSTTY_KEY_BRACKET_RIGHT,
115 GHOSTTY_KEY_COMMA,
116 GHOSTTY_KEY_DIGIT_0,
117 GHOSTTY_KEY_DIGIT_1,
118 GHOSTTY_KEY_DIGIT_2,
119 GHOSTTY_KEY_DIGIT_3,
120 GHOSTTY_KEY_DIGIT_4,
121 GHOSTTY_KEY_DIGIT_5,
122 GHOSTTY_KEY_DIGIT_6,
123 GHOSTTY_KEY_DIGIT_7,
124 GHOSTTY_KEY_DIGIT_8,
125 GHOSTTY_KEY_DIGIT_9,
126 GHOSTTY_KEY_EQUAL,
127 GHOSTTY_KEY_INTL_BACKSLASH,
128 GHOSTTY_KEY_INTL_RO,
129 GHOSTTY_KEY_INTL_YEN,
130 GHOSTTY_KEY_A,
131 GHOSTTY_KEY_B,
132 GHOSTTY_KEY_C,
133 GHOSTTY_KEY_D,
134 GHOSTTY_KEY_E,
135 GHOSTTY_KEY_F,
136 GHOSTTY_KEY_G,
137 GHOSTTY_KEY_H,
138 GHOSTTY_KEY_I,
139 GHOSTTY_KEY_J,
140 GHOSTTY_KEY_K,
141 GHOSTTY_KEY_L,
142 GHOSTTY_KEY_M,
143 GHOSTTY_KEY_N,
144 GHOSTTY_KEY_O,
145 GHOSTTY_KEY_P,
146 GHOSTTY_KEY_Q,
147 GHOSTTY_KEY_R,
148 GHOSTTY_KEY_S,
149 GHOSTTY_KEY_T,
150 GHOSTTY_KEY_U,
151 GHOSTTY_KEY_V,
152 GHOSTTY_KEY_W,
153 GHOSTTY_KEY_X,
154 GHOSTTY_KEY_Y,
155 GHOSTTY_KEY_Z,
156 GHOSTTY_KEY_MINUS,
157 GHOSTTY_KEY_PERIOD,
158 GHOSTTY_KEY_QUOTE,
159 GHOSTTY_KEY_SEMICOLON,
160 GHOSTTY_KEY_SLASH,
161
162 // Functional Keys (W3C § 3.1.2)
163 GHOSTTY_KEY_ALT_LEFT,
164 GHOSTTY_KEY_ALT_RIGHT,
165 GHOSTTY_KEY_BACKSPACE,
166 GHOSTTY_KEY_CAPS_LOCK,
167 GHOSTTY_KEY_CONTEXT_MENU,
168 GHOSTTY_KEY_CONTROL_LEFT,
169 GHOSTTY_KEY_CONTROL_RIGHT,
170 GHOSTTY_KEY_ENTER,
171 GHOSTTY_KEY_META_LEFT,
172 GHOSTTY_KEY_META_RIGHT,
173 GHOSTTY_KEY_SHIFT_LEFT,
174 GHOSTTY_KEY_SHIFT_RIGHT,
175 GHOSTTY_KEY_SPACE,
176 GHOSTTY_KEY_TAB,
177 GHOSTTY_KEY_CONVERT,
178 GHOSTTY_KEY_KANA_MODE,
179 GHOSTTY_KEY_NON_CONVERT,
180
181 // Control Pad Section (W3C § 3.2)
182 GHOSTTY_KEY_DELETE,
183 GHOSTTY_KEY_END,
184 GHOSTTY_KEY_HELP,
185 GHOSTTY_KEY_HOME,
186 GHOSTTY_KEY_INSERT,
187 GHOSTTY_KEY_PAGE_DOWN,
188 GHOSTTY_KEY_PAGE_UP,
189
190 // Arrow Pad Section (W3C § 3.3)
191 GHOSTTY_KEY_ARROW_DOWN,
192 GHOSTTY_KEY_ARROW_LEFT,
193 GHOSTTY_KEY_ARROW_RIGHT,
194 GHOSTTY_KEY_ARROW_UP,
195
196 // Numpad Section (W3C § 3.4)
197 GHOSTTY_KEY_NUM_LOCK,
198 GHOSTTY_KEY_NUMPAD_0,
199 GHOSTTY_KEY_NUMPAD_1,
200 GHOSTTY_KEY_NUMPAD_2,
201 GHOSTTY_KEY_NUMPAD_3,
202 GHOSTTY_KEY_NUMPAD_4,
203 GHOSTTY_KEY_NUMPAD_5,
204 GHOSTTY_KEY_NUMPAD_6,
205 GHOSTTY_KEY_NUMPAD_7,
206 GHOSTTY_KEY_NUMPAD_8,
207 GHOSTTY_KEY_NUMPAD_9,
208 GHOSTTY_KEY_NUMPAD_ADD,
209 GHOSTTY_KEY_NUMPAD_BACKSPACE,
210 GHOSTTY_KEY_NUMPAD_CLEAR,
211 GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,
212 GHOSTTY_KEY_NUMPAD_COMMA,
213 GHOSTTY_KEY_NUMPAD_DECIMAL,
214 GHOSTTY_KEY_NUMPAD_DIVIDE,
215 GHOSTTY_KEY_NUMPAD_ENTER,
216 GHOSTTY_KEY_NUMPAD_EQUAL,
217 GHOSTTY_KEY_NUMPAD_MEMORY_ADD,
218 GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,
219 GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,
220 GHOSTTY_KEY_NUMPAD_MEMORY_STORE,
221 GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,
222 GHOSTTY_KEY_NUMPAD_MULTIPLY,
223 GHOSTTY_KEY_NUMPAD_PAREN_LEFT,
224 GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,
225 GHOSTTY_KEY_NUMPAD_SUBTRACT,
226 GHOSTTY_KEY_NUMPAD_SEPARATOR,
227 GHOSTTY_KEY_NUMPAD_UP,
228 GHOSTTY_KEY_NUMPAD_DOWN,
229 GHOSTTY_KEY_NUMPAD_RIGHT,
230 GHOSTTY_KEY_NUMPAD_LEFT,
231 GHOSTTY_KEY_NUMPAD_BEGIN,
232 GHOSTTY_KEY_NUMPAD_HOME,
233 GHOSTTY_KEY_NUMPAD_END,
234 GHOSTTY_KEY_NUMPAD_INSERT,
235 GHOSTTY_KEY_NUMPAD_DELETE,
236 GHOSTTY_KEY_NUMPAD_PAGE_UP,
237 GHOSTTY_KEY_NUMPAD_PAGE_DOWN,
238
239 // Function Section (W3C § 3.5)
240 GHOSTTY_KEY_ESCAPE,
241 GHOSTTY_KEY_F1,
242 GHOSTTY_KEY_F2,
243 GHOSTTY_KEY_F3,
244 GHOSTTY_KEY_F4,
245 GHOSTTY_KEY_F5,
246 GHOSTTY_KEY_F6,
247 GHOSTTY_KEY_F7,
248 GHOSTTY_KEY_F8,
249 GHOSTTY_KEY_F9,
250 GHOSTTY_KEY_F10,
251 GHOSTTY_KEY_F11,
252 GHOSTTY_KEY_F12,
253 GHOSTTY_KEY_F13,
254 GHOSTTY_KEY_F14,
255 GHOSTTY_KEY_F15,
256 GHOSTTY_KEY_F16,
257 GHOSTTY_KEY_F17,
258 GHOSTTY_KEY_F18,
259 GHOSTTY_KEY_F19,
260 GHOSTTY_KEY_F20,
261 GHOSTTY_KEY_F21,
262 GHOSTTY_KEY_F22,
263 GHOSTTY_KEY_F23,
264 GHOSTTY_KEY_F24,
265 GHOSTTY_KEY_F25,
266 GHOSTTY_KEY_FN,
267 GHOSTTY_KEY_FN_LOCK,
268 GHOSTTY_KEY_PRINT_SCREEN,
269 GHOSTTY_KEY_SCROLL_LOCK,
270 GHOSTTY_KEY_PAUSE,
271
272 // Media Keys (W3C § 3.6)
273 GHOSTTY_KEY_BROWSER_BACK,
274 GHOSTTY_KEY_BROWSER_FAVORITES,
275 GHOSTTY_KEY_BROWSER_FORWARD,
276 GHOSTTY_KEY_BROWSER_HOME,
277 GHOSTTY_KEY_BROWSER_REFRESH,
278 GHOSTTY_KEY_BROWSER_SEARCH,
279 GHOSTTY_KEY_BROWSER_STOP,
280 GHOSTTY_KEY_EJECT,
281 GHOSTTY_KEY_LAUNCH_APP_1,
282 GHOSTTY_KEY_LAUNCH_APP_2,
283 GHOSTTY_KEY_LAUNCH_MAIL,
284 GHOSTTY_KEY_MEDIA_PLAY_PAUSE,
285 GHOSTTY_KEY_MEDIA_SELECT,
286 GHOSTTY_KEY_MEDIA_STOP,
287 GHOSTTY_KEY_MEDIA_TRACK_NEXT,
288 GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,
289 GHOSTTY_KEY_POWER,
290 GHOSTTY_KEY_SLEEP,
291 GHOSTTY_KEY_AUDIO_VOLUME_DOWN,
292 GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
293 GHOSTTY_KEY_AUDIO_VOLUME_UP,
294 GHOSTTY_KEY_WAKE_UP,
295
296 // Legacy, Non-standard, and Special Keys (W3C § 3.7)
297 GHOSTTY_KEY_COPY,
298 GHOSTTY_KEY_CUT,
299 GHOSTTY_KEY_PASTE,
300 GHOSTTY_KEY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
301} GhosttyKey;
302
303/**
304 * Create a new key event instance.
305 *
306 * Creates a new key event with default values. The event must be freed using
307 * ghostty_key_event_free() when no longer needed.
308 *
309 * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
310 * @param event Pointer to store the created key event handle
311 * @return GHOSTTY_SUCCESS on success, or an error code on failure
312 *
313 * @ingroup key
314 */
315GHOSTTY_API GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event);
316
317/**
318 * Free a key event instance.
319 *
320 * Releases all resources associated with the key event. After this call,
321 * the event handle becomes invalid and must not be used.
322 *
323 * @param event The key event handle to free (may be NULL)
324 *
325 * @ingroup key
326 */
327GHOSTTY_API void ghostty_key_event_free(GhosttyKeyEvent event);
328
329/**
330 * Set the key action (press, release, repeat).
331 *
332 * @param event The key event handle, must not be NULL
333 * @param action The action to set
334 *
335 * @ingroup key
336 */
337GHOSTTY_API void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action);
338
339/**
340 * Get the key action (press, release, repeat).
341 *
342 * @param event The key event handle, must not be NULL
343 * @return The key action
344 *
345 * @ingroup key
346 */
347GHOSTTY_API GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event);
348
349/**
350 * Set the physical key code.
351 *
352 * @param event The key event handle, must not be NULL
353 * @param key The physical key code to set
354 *
355 * @ingroup key
356 */
357GHOSTTY_API void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key);
358
359/**
360 * Get the physical key code.
361 *
362 * @param event The key event handle, must not be NULL
363 * @return The physical key code
364 *
365 * @ingroup key
366 */
367GHOSTTY_API GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event);
368
369/**
370 * Set the modifier keys bitmask.
371 *
372 * @param event The key event handle, must not be NULL
373 * @param mods The modifier keys bitmask to set
374 *
375 * @ingroup key
376 */
377GHOSTTY_API void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods);
378
379/**
380 * Get the modifier keys bitmask.
381 *
382 * @param event The key event handle, must not be NULL
383 * @return The modifier keys bitmask
384 *
385 * @ingroup key
386 */
387GHOSTTY_API GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event);
388
389/**
390 * Set the consumed modifiers bitmask.
391 *
392 * @param event The key event handle, must not be NULL
393 * @param consumed_mods The consumed modifiers bitmask to set
394 *
395 * @ingroup key
396 */
397GHOSTTY_API void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods);
398
399/**
400 * Get the consumed modifiers bitmask.
401 *
402 * @param event The key event handle, must not be NULL
403 * @return The consumed modifiers bitmask
404 *
405 * @ingroup key
406 */
407GHOSTTY_API GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event);
408
409/**
410 * Set whether the key event is part of a composition sequence.
411 *
412 * @param event The key event handle, must not be NULL
413 * @param composing Whether the key event is part of a composition sequence
414 *
415 * @ingroup key
416 */
417GHOSTTY_API void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing);
418
419/**
420 * Get whether the key event is part of a composition sequence.
421 *
422 * @param event The key event handle, must not be NULL
423 * @return Whether the key event is part of a composition sequence
424 *
425 * @ingroup key
426 */
427GHOSTTY_API bool ghostty_key_event_get_composing(GhosttyKeyEvent event);
428
429/**
430 * Set the UTF-8 text generated by the key for the current keyboard layout.
431 *
432 * Must contain the unmodified character before any Ctrl/Meta transformations.
433 * The encoder derives modifier sequences from the logical key and mods
434 * bitmask, not from this text. Do not pass C0 control characters
435 * (U+0000-U+001F, U+007F) or platform function key codes (e.g. macOS PUA
436 * U+F700-U+F8FF); pass NULL instead and let the encoder use the logical key.
437 *
438 * The key event does NOT take ownership of the text pointer. The caller
439 * must ensure the string remains valid for the lifetime needed by the event.
440 *
441 * @param event The key event handle, must not be NULL
442 * @param utf8 The UTF-8 text to set (or NULL for empty)
443 * @param len Length of the UTF-8 text in bytes
444 *
445 * @ingroup key
446 */
447GHOSTTY_API void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len);
448
449/**
450 * Get the UTF-8 text generated by the key event.
451 *
452 * The returned pointer is valid until the event is freed or the UTF-8 text is modified.
453 *
454 * @param event The key event handle, must not be NULL
455 * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL)
456 * @return The UTF-8 text (or NULL for empty)
457 *
458 * @ingroup key
459 */
460GHOSTTY_API const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len);
461
462/**
463 * Set the unshifted Unicode codepoint.
464 *
465 * @param event The key event handle, must not be NULL
466 * @param codepoint The unshifted Unicode codepoint to set
467 *
468 * @ingroup key
469 */
470GHOSTTY_API void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint);
471
472/**
473 * Get the unshifted Unicode codepoint.
474 *
475 * @param event The key event handle, must not be NULL
476 * @return The unshifted Unicode codepoint
477 *
478 * @ingroup key
479 */
480GHOSTTY_API uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event);
481
482#endif /* GHOSTTY_VT_KEY_EVENT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h created+775
...@@ -0,0 +1,775 @@
1/**
2 * @file kitty_graphics.h
3 *
4 * Kitty graphics protocol
5 *
6 * See @ref kitty_graphics for a full usage guide.
7 */
8
9#ifndef GHOSTTY_VT_KITTY_GRAPHICS_H
10#define GHOSTTY_VT_KITTY_GRAPHICS_H
11
12#include <stdbool.h>
13#include <stdint.h>
14#include <ghostty/vt/allocator.h>
15#include <ghostty/vt/selection.h>
16#include <ghostty/vt/types.h>
17
18#ifdef __cplusplus
19extern "C" {
20#endif
21
22/** @defgroup kitty_graphics Kitty Graphics
23 *
24 * API for inspecting images and placements stored via the
25 * [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/).
26 *
27 * The central object is @ref GhosttyKittyGraphics, an opaque handle to
28 * the image storage associated with a terminal's active screen. From it
29 * you can iterate over placements and look up individual images.
30 *
31 * ## Obtaining a KittyGraphics Handle
32 *
33 * A @ref GhosttyKittyGraphics handle is obtained from a terminal via
34 * ghostty_terminal_get() with @ref GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS.
35 * The handle is borrowed from the terminal and remains valid until the
36 * next mutating terminal call (e.g. ghostty_terminal_vt_write() or
37 * ghostty_terminal_reset()).
38 *
39 * Before images can be stored, Kitty graphics must be enabled on the
40 * terminal by setting a non-zero storage limit with
41 * @ref GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, and a PNG
42 * decoder callback must be installed via ghostty_sys_set() with
43 * @ref GHOSTTY_SYS_OPT_DECODE_PNG.
44 *
45 * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png
46 *
47 * ## Iterating Placements
48 *
49 * Placements are inspected through a @ref GhosttyKittyGraphicsPlacementIterator.
50 * The typical workflow is:
51 *
52 * 1. Create an iterator with ghostty_kitty_graphics_placement_iterator_new().
53 * 2. Populate it from the storage with ghostty_kitty_graphics_get() using
54 * @ref GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR.
55 * 3. Optionally filter by z-layer with
56 * ghostty_kitty_graphics_placement_iterator_set().
57 * 4. Advance with ghostty_kitty_graphics_placement_next() and read
58 * per-placement data with ghostty_kitty_graphics_placement_get().
59 * 5. For each placement, look up its image with
60 * ghostty_kitty_graphics_image() to access pixel data and dimensions.
61 * 6. Free the iterator with ghostty_kitty_graphics_placement_iterator_free().
62 *
63 * ## Looking Up Images
64 *
65 * Given an image ID (obtained from a placement via
66 * @ref GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID), call
67 * ghostty_kitty_graphics_image() to get a @ref GhosttyKittyGraphicsImage
68 * handle. From this handle, ghostty_kitty_graphics_image_get() provides
69 * the image dimensions, pixel format, compression, and a borrowed pointer
70 * to the raw pixel data.
71 *
72 * ## Rendering Helpers
73 *
74 * Several functions assist with rendering a placement:
75 *
76 * - ghostty_kitty_graphics_placement_pixel_size() — rendered pixel
77 * dimensions accounting for source rect and aspect ratio.
78 * - ghostty_kitty_graphics_placement_grid_size() — number of grid
79 * columns and rows the placement occupies.
80 * - ghostty_kitty_graphics_placement_viewport_pos() — viewport-relative
81 * grid position (may be negative for partially scrolled placements).
82 * - ghostty_kitty_graphics_placement_source_rect() — resolved source
83 * rectangle in pixels, clamped to image bounds.
84 * - ghostty_kitty_graphics_placement_rect() — bounding rectangle as a
85 * @ref GhosttySelection.
86 *
87 * ## Lifetime and Thread Safety
88 *
89 * All handles borrowed from the terminal (GhosttyKittyGraphics,
90 * GhosttyKittyGraphicsImage) are invalidated by any mutating terminal
91 * call. The placement iterator is independently owned and must be freed
92 * by the caller, but the data it yields is only valid while the
93 * underlying terminal is not mutated.
94 *
95 * ## Example
96 *
97 * The following example creates a terminal, sends a Kitty graphics
98 * image, then iterates placements and prints image metadata:
99 *
100 * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main
101 *
102 * @{
103 */
104
105/**
106 * Queryable data kinds for ghostty_kitty_graphics_get().
107 *
108 * @ingroup kitty_graphics
109 */
110typedef enum GHOSTTY_ENUM_TYPED {
111 /** Invalid / sentinel value. */
112 GHOSTTY_KITTY_GRAPHICS_DATA_INVALID = 0,
113
114 /**
115 * Populate a pre-allocated placement iterator with placement data from
116 * the storage. Iterator data is only valid as long as the underlying
117 * terminal is not mutated.
118 *
119 * Output type: GhosttyKittyGraphicsPlacementIterator *
120 */
121 GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1,
122 GHOSTTY_KITTY_GRAPHICS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
123} GhosttyKittyGraphicsData;
124
125/**
126 * Queryable data kinds for ghostty_kitty_graphics_placement_get().
127 *
128 * @ingroup kitty_graphics
129 */
130typedef enum GHOSTTY_ENUM_TYPED {
131 /** Invalid / sentinel value. */
132 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_INVALID = 0,
133
134 /**
135 * The image ID this placement belongs to.
136 *
137 * Output type: uint32_t *
138 */
139 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID = 1,
140
141 /**
142 * The placement ID.
143 *
144 * Output type: uint32_t *
145 */
146 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID = 2,
147
148 /**
149 * Whether this is a virtual placement (unicode placeholder).
150 *
151 * Output type: bool *
152 */
153 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL = 3,
154
155 /**
156 * Pixel offset from the left edge of the cell.
157 *
158 * Output type: uint32_t *
159 */
160 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET = 4,
161
162 /**
163 * Pixel offset from the top edge of the cell.
164 *
165 * Output type: uint32_t *
166 */
167 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET = 5,
168
169 /**
170 * Source rectangle x origin in pixels.
171 *
172 * Output type: uint32_t *
173 */
174 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_X = 6,
175
176 /**
177 * Source rectangle y origin in pixels.
178 *
179 * Output type: uint32_t *
180 */
181 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_Y = 7,
182
183 /**
184 * Source rectangle width in pixels (0 = full image width).
185 *
186 * Output type: uint32_t *
187 */
188 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_WIDTH = 8,
189
190 /**
191 * Source rectangle height in pixels (0 = full image height).
192 *
193 * Output type: uint32_t *
194 */
195 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_HEIGHT = 9,
196
197 /**
198 * Number of columns this placement occupies.
199 *
200 * Output type: uint32_t *
201 */
202 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_COLUMNS = 10,
203
204 /**
205 * Number of rows this placement occupies.
206 *
207 * Output type: uint32_t *
208 */
209 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_ROWS = 11,
210
211 /**
212 * Z-index for this placement.
213 *
214 * Output type: int32_t *
215 */
216 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z = 12,
217
218 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
219} GhosttyKittyGraphicsPlacementData;
220
221/**
222 * Z-layer classification for kitty graphics placements.
223 *
224 * Based on the kitty protocol z-index conventions:
225 * - BELOW_BG: z < INT32_MIN/2 (drawn below cell background)
226 * - BELOW_TEXT: INT32_MIN/2 <= z < 0 (above background, below text)
227 * - ABOVE_TEXT: z >= 0 (above text)
228 * - ALL: no filtering (current behavior)
229 *
230 * @ingroup kitty_graphics
231 */
232typedef enum GHOSTTY_ENUM_TYPED {
233 GHOSTTY_KITTY_PLACEMENT_LAYER_ALL = 0,
234 GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_BG = 1,
235 GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT = 2,
236 GHOSTTY_KITTY_PLACEMENT_LAYER_ABOVE_TEXT = 3,
237 GHOSTTY_KITTY_PLACEMENT_LAYER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
238} GhosttyKittyPlacementLayer;
239
240/**
241 * Settable options for ghostty_kitty_graphics_placement_iterator_set().
242 *
243 * @ingroup kitty_graphics
244 */
245typedef enum GHOSTTY_ENUM_TYPED {
246 /**
247 * Set the z-layer filter for the iterator.
248 *
249 * Input type: GhosttyKittyPlacementLayer *
250 */
251 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER = 0,
252 GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
253} GhosttyKittyGraphicsPlacementIteratorOption;
254
255/**
256 * Pixel format of a Kitty graphics image.
257 *
258 * @ingroup kitty_graphics
259 */
260typedef enum GHOSTTY_ENUM_TYPED {
261 GHOSTTY_KITTY_IMAGE_FORMAT_RGB = 0,
262 GHOSTTY_KITTY_IMAGE_FORMAT_RGBA = 1,
263 GHOSTTY_KITTY_IMAGE_FORMAT_PNG = 2,
264 GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA = 3,
265 GHOSTTY_KITTY_IMAGE_FORMAT_GRAY = 4,
266 GHOSTTY_KITTY_IMAGE_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
267} GhosttyKittyImageFormat;
268
269/**
270 * Compression of a Kitty graphics image.
271 *
272 * @ingroup kitty_graphics
273 */
274typedef enum GHOSTTY_ENUM_TYPED {
275 GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE = 0,
276 GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE = 1,
277 GHOSTTY_KITTY_IMAGE_COMPRESSION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
278} GhosttyKittyImageCompression;
279
280/**
281 * Queryable data kinds for ghostty_kitty_graphics_image_get().
282 *
283 * @ingroup kitty_graphics
284 */
285typedef enum GHOSTTY_ENUM_TYPED {
286 /** Invalid / sentinel value. */
287 GHOSTTY_KITTY_IMAGE_DATA_INVALID = 0,
288
289 /**
290 * The image ID.
291 *
292 * Output type: uint32_t *
293 */
294 GHOSTTY_KITTY_IMAGE_DATA_ID = 1,
295
296 /**
297 * The image number.
298 *
299 * Output type: uint32_t *
300 */
301 GHOSTTY_KITTY_IMAGE_DATA_NUMBER = 2,
302
303 /**
304 * Image width in pixels.
305 *
306 * Output type: uint32_t *
307 */
308 GHOSTTY_KITTY_IMAGE_DATA_WIDTH = 3,
309
310 /**
311 * Image height in pixels.
312 *
313 * Output type: uint32_t *
314 */
315 GHOSTTY_KITTY_IMAGE_DATA_HEIGHT = 4,
316
317 /**
318 * Pixel format of the image.
319 *
320 * Output type: GhosttyKittyImageFormat *
321 */
322 GHOSTTY_KITTY_IMAGE_DATA_FORMAT = 5,
323
324 /**
325 * Compression of the image.
326 *
327 * Output type: GhosttyKittyImageCompression *
328 */
329 GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION = 6,
330
331 /**
332 * Borrowed pointer to the raw pixel data. Valid as long as the
333 * underlying terminal is not mutated.
334 *
335 * Output type: const uint8_t **
336 */
337 GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7,
338
339 /**
340 * Length of the raw pixel data in bytes.
341 *
342 * Output type: size_t *
343 */
344 GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8,
345
346 GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
347} GhosttyKittyGraphicsImageData;
348
349/**
350 * Combined rendering geometry for a placement in a single sized struct.
351 *
352 * Combines the results of ghostty_kitty_graphics_placement_pixel_size(),
353 * ghostty_kitty_graphics_placement_grid_size(),
354 * ghostty_kitty_graphics_placement_viewport_pos(), and
355 * ghostty_kitty_graphics_placement_source_rect() into one call. This is
356 * an optimization over calling those four functions individually,
357 * particularly useful in environments with high per-call overhead such
358 * as FFI or Cgo.
359 *
360 * This struct uses the sized-struct ABI pattern. Initialize with
361 * GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo) before calling
362 * ghostty_kitty_graphics_placement_render_info().
363 *
364 * @ingroup kitty_graphics
365 */
366typedef struct {
367 /** Size of this struct in bytes. Must be set to sizeof(GhosttyKittyGraphicsPlacementRenderInfo). */
368 size_t size;
369 /** Rendered width in pixels. */
370 uint32_t pixel_width;
371 /** Rendered height in pixels. */
372 uint32_t pixel_height;
373 /** Number of grid columns the placement occupies. */
374 uint32_t grid_cols;
375 /** Number of grid rows the placement occupies. */
376 uint32_t grid_rows;
377 /** Viewport-relative column (may be negative for partially visible placements). */
378 int32_t viewport_col;
379 /** Viewport-relative row (may be negative for partially visible placements). */
380 int32_t viewport_row;
381 /** False when the placement is fully off-screen or virtual. */
382 bool viewport_visible;
383 /** Resolved source rectangle x origin in pixels. */
384 uint32_t source_x;
385 /** Resolved source rectangle y origin in pixels. */
386 uint32_t source_y;
387 /** Resolved source rectangle width in pixels. */
388 uint32_t source_width;
389 /** Resolved source rectangle height in pixels. */
390 uint32_t source_height;
391} GhosttyKittyGraphicsPlacementRenderInfo;
392
393/**
394 * Get data from a kitty graphics storage instance.
395 *
396 * The output pointer must be of the appropriate type for the requested
397 * data kind.
398 *
399 * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
400 *
401 * @param graphics The kitty graphics handle
402 * @param data The type of data to extract
403 * @param[out] out Pointer to store the extracted data
404 * @return GHOSTTY_SUCCESS on success
405 *
406 * @ingroup kitty_graphics
407 */
408GHOSTTY_API GhosttyResult ghostty_kitty_graphics_get(
409 GhosttyKittyGraphics graphics,
410 GhosttyKittyGraphicsData data,
411 void* out);
412
413/**
414 * Look up a Kitty graphics image by its image ID.
415 *
416 * Returns NULL if no image with the given ID exists or if Kitty graphics
417 * are disabled at build time.
418 *
419 * @param graphics The kitty graphics handle
420 * @param image_id The image ID to look up
421 * @return An opaque image handle, or NULL if not found
422 *
423 * @ingroup kitty_graphics
424 */
425GHOSTTY_API GhosttyKittyGraphicsImage ghostty_kitty_graphics_image(
426 GhosttyKittyGraphics graphics,
427 uint32_t image_id);
428
429/**
430 * Get data from a Kitty graphics image.
431 *
432 * The output pointer must be of the appropriate type for the requested
433 * data kind.
434 *
435 * @param image The image handle (NULL returns GHOSTTY_INVALID_VALUE)
436 * @param data The data kind to query
437 * @param[out] out Pointer to receive the queried value
438 * @return GHOSTTY_SUCCESS on success
439 *
440 * @ingroup kitty_graphics
441 */
442GHOSTTY_API GhosttyResult ghostty_kitty_graphics_image_get(
443 GhosttyKittyGraphicsImage image,
444 GhosttyKittyGraphicsImageData data,
445 void* out);
446
447/**
448 * Get multiple data fields from a Kitty graphics image in a single call.
449 *
450 * This is an optimization over calling ghostty_kitty_graphics_image_get()
451 * repeatedly, particularly useful in environments with high per-call
452 * overhead such as FFI or Cgo.
453 *
454 * Each element in the keys array specifies a data kind, and the
455 * corresponding element in the values array receives the result.
456 * The type of each values[i] pointer must match the output type
457 * documented for keys[i].
458 *
459 * Processing stops at the first error; on success out_written
460 * is set to count, on error it is set to the index of the
461 * failing key (i.e. the number of values successfully written).
462 *
463 * @param image The image handle (NULL returns GHOSTTY_INVALID_VALUE)
464 * @param count Number of key/value pairs
465 * @param keys Array of data kinds to query
466 * @param values Array of output pointers (types must match each key's
467 * documented output type)
468 * @param[out] out_written On return, receives the number of values
469 * successfully written (may be NULL)
470 * @return GHOSTTY_SUCCESS if all queries succeed
471 *
472 * @ingroup kitty_graphics
473 */
474GHOSTTY_API GhosttyResult ghostty_kitty_graphics_image_get_multi(
475 GhosttyKittyGraphicsImage image,
476 size_t count,
477 const GhosttyKittyGraphicsImageData* keys,
478 void** values,
479 size_t* out_written);
480
481/**
482 * Create a new placement iterator instance.
483 *
484 * All fields except the allocator are left undefined until populated
485 * via ghostty_kitty_graphics_get() with
486 * GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR.
487 *
488 * @param allocator Pointer to allocator, or NULL to use the default allocator
489 * @param[out] out_iterator On success, receives the created iterator handle
490 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation
491 * failure
492 *
493 * @ingroup kitty_graphics
494 */
495GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_new(
496 const GhosttyAllocator* allocator,
497 GhosttyKittyGraphicsPlacementIterator* out_iterator);
498
499/**
500 * Free a placement iterator.
501 *
502 * @param iterator The iterator handle to free (may be NULL)
503 *
504 * @ingroup kitty_graphics
505 */
506GHOSTTY_API void ghostty_kitty_graphics_placement_iterator_free(
507 GhosttyKittyGraphicsPlacementIterator iterator);
508
509/**
510 * Set an option on a placement iterator.
511 *
512 * Use GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER with a
513 * GhosttyKittyPlacementLayer value to filter placements by z-layer.
514 * The filter is applied during iteration: ghostty_kitty_graphics_placement_next()
515 * will skip placements that do not match the configured layer.
516 *
517 * The default layer is GHOSTTY_KITTY_PLACEMENT_LAYER_ALL (no filtering).
518 *
519 * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE)
520 * @param option The option to set
521 * @param value Pointer to the value (type depends on option; NULL returns
522 * GHOSTTY_INVALID_VALUE)
523 * @return GHOSTTY_SUCCESS on success
524 *
525 * @ingroup kitty_graphics
526 */
527GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_set(
528 GhosttyKittyGraphicsPlacementIterator iterator,
529 GhosttyKittyGraphicsPlacementIteratorOption option,
530 const void* value);
531
532/**
533 * Advance the placement iterator to the next placement.
534 *
535 * If a layer filter has been set via
536 * ghostty_kitty_graphics_placement_iterator_set(), only placements
537 * matching that layer are returned.
538 *
539 * @param iterator The iterator handle (may be NULL)
540 * @return true if advanced to the next placement, false if at the end
541 *
542 * @ingroup kitty_graphics
543 */
544GHOSTTY_API bool ghostty_kitty_graphics_placement_next(
545 GhosttyKittyGraphicsPlacementIterator iterator);
546
547/**
548 * Get data from the current placement in a placement iterator.
549 *
550 * Call ghostty_kitty_graphics_placement_next() at least once before
551 * calling this function.
552 *
553 * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE)
554 * @param data The data kind to query
555 * @param[out] out Pointer to receive the queried value
556 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the
557 * iterator is NULL or not positioned on a placement
558 *
559 * @ingroup kitty_graphics
560 */
561GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get(
562 GhosttyKittyGraphicsPlacementIterator iterator,
563 GhosttyKittyGraphicsPlacementData data,
564 void* out);
565
566/**
567 * Get multiple data fields from the current placement in a single call.
568 *
569 * This is an optimization over calling ghostty_kitty_graphics_placement_get()
570 * repeatedly, particularly useful in environments with high per-call
571 * overhead such as FFI or Cgo.
572 *
573 * Each element in the keys array specifies a data kind, and the
574 * corresponding element in the values array receives the result.
575 * The type of each values[i] pointer must match the output type
576 * documented for keys[i].
577 *
578 * Processing stops at the first error; on success out_written
579 * is set to count, on error it is set to the index of the
580 * failing key (i.e. the number of values successfully written).
581 *
582 * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE)
583 * @param count Number of key/value pairs
584 * @param keys Array of data kinds to query
585 * @param values Array of output pointers (types must match each key's
586 * documented output type)
587 * @param[out] out_written On return, receives the number of values
588 * successfully written (may be NULL)
589 * @return GHOSTTY_SUCCESS if all queries succeed
590 *
591 * @ingroup kitty_graphics
592 */
593GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get_multi(
594 GhosttyKittyGraphicsPlacementIterator iterator,
595 size_t count,
596 const GhosttyKittyGraphicsPlacementData* keys,
597 void** values,
598 size_t* out_written);
599
600/**
601 * Compute the grid rectangle occupied by the current placement.
602 *
603 * Uses the placement's pin, the image dimensions, and the terminal's
604 * cell/pixel geometry to calculate the bounding rectangle. Virtual
605 * placements (unicode placeholders) return GHOSTTY_NO_VALUE.
606 *
607 * @param terminal The terminal handle
608 * @param image The image handle for this placement's image
609 * @param iterator The placement iterator positioned on a placement
610 * @param[out] out_selection On success, receives the bounding rectangle
611 * as a selection with rectangle=true
612 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle
613 * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE for
614 * virtual placements or when Kitty graphics are disabled
615 *
616 * @ingroup kitty_graphics
617 */
618GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_rect(
619 GhosttyKittyGraphicsPlacementIterator iterator,
620 GhosttyKittyGraphicsImage image,
621 GhosttyTerminal terminal,
622 GhosttySelection* out_selection);
623
624/**
625 * Compute the rendered pixel size of the current placement.
626 *
627 * Takes into account the placement's source rectangle, specified
628 * columns/rows, and aspect ratio to calculate the final rendered
629 * pixel dimensions.
630 *
631 * @param iterator The placement iterator positioned on a placement
632 * @param image The image handle for this placement's image
633 * @param terminal The terminal handle
634 * @param[out] out_width On success, receives the width in pixels
635 * @param[out] out_height On success, receives the height in pixels
636 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle
637 * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when
638 * Kitty graphics are disabled
639 *
640 * @ingroup kitty_graphics
641 */
642GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_pixel_size(
643 GhosttyKittyGraphicsPlacementIterator iterator,
644 GhosttyKittyGraphicsImage image,
645 GhosttyTerminal terminal,
646 uint32_t* out_width,
647 uint32_t* out_height);
648
649/**
650 * Compute the grid cell size of the current placement.
651 *
652 * Returns the number of columns and rows that the placement occupies
653 * in the terminal grid. If the placement specifies explicit columns
654 * and rows, those are returned directly; otherwise they are calculated
655 * from the pixel size and cell dimensions.
656 *
657 * @param iterator The placement iterator positioned on a placement
658 * @param image The image handle for this placement's image
659 * @param terminal The terminal handle
660 * @param[out] out_cols On success, receives the number of columns
661 * @param[out] out_rows On success, receives the number of rows
662 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle
663 * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when
664 * Kitty graphics are disabled
665 *
666 * @ingroup kitty_graphics
667 */
668GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_grid_size(
669 GhosttyKittyGraphicsPlacementIterator iterator,
670 GhosttyKittyGraphicsImage image,
671 GhosttyTerminal terminal,
672 uint32_t* out_cols,
673 uint32_t* out_rows);
674
675/**
676 * Get the viewport-relative grid position of the current placement.
677 *
678 * Converts the placement's internal pin to viewport-relative column and
679 * row coordinates. The returned coordinates represent the top-left
680 * corner of the placement in the viewport's grid coordinate space.
681 *
682 * The row value can be negative when the placement's origin has
683 * scrolled above the top of the viewport. For example, a 4-row
684 * image that has scrolled up by 2 rows returns row=-2, meaning
685 * its top 2 rows are above the visible area but its bottom 2 rows
686 * are still on screen. Embedders should use these coordinates
687 * directly when computing the destination rectangle for rendering;
688 * the embedder is responsible for clipping the portion of the image
689 * that falls outside the viewport.
690 *
691 * Returns GHOSTTY_SUCCESS for any placement that is at least
692 * partially visible in the viewport. Returns GHOSTTY_NO_VALUE when
693 * the placement is completely outside the viewport (its bottom edge
694 * is above the viewport or its top edge is at or below the last
695 * viewport row), or when the placement is a virtual (unicode
696 * placeholder) placement.
697 *
698 * @param iterator The placement iterator positioned on a placement
699 * @param image The image handle for this placement's image
700 * @param terminal The terminal handle
701 * @param[out] out_col On success, receives the viewport-relative column
702 * @param[out] out_row On success, receives the viewport-relative row
703 * (may be negative for partially visible placements)
704 * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if fully
705 * off-screen or virtual, GHOSTTY_INVALID_VALUE if any handle
706 * is NULL or the iterator is not positioned
707 *
708 * @ingroup kitty_graphics
709 */
710GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_viewport_pos(
711 GhosttyKittyGraphicsPlacementIterator iterator,
712 GhosttyKittyGraphicsImage image,
713 GhosttyTerminal terminal,
714 int32_t* out_col,
715 int32_t* out_row);
716
717/**
718 * Get the resolved source rectangle for the current placement.
719 *
720 * Applies kitty protocol semantics: a width or height of 0 in the
721 * placement means "use the full image dimension", and the resulting
722 * rectangle is clamped to the actual image bounds. The returned
723 * values are in pixels and are ready to use for texture sampling.
724 *
725 * @param iterator The placement iterator positioned on a placement
726 * @param image The image handle for this placement's image
727 * @param[out] out_x Source rect x origin in pixels
728 * @param[out] out_y Source rect y origin in pixels
729 * @param[out] out_width Source rect width in pixels
730 * @param[out] out_height Source rect height in pixels
731 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any
732 * handle is NULL or the iterator is not positioned
733 *
734 * @ingroup kitty_graphics
735 */
736GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_source_rect(
737 GhosttyKittyGraphicsPlacementIterator iterator,
738 GhosttyKittyGraphicsImage image,
739 uint32_t* out_x,
740 uint32_t* out_y,
741 uint32_t* out_width,
742 uint32_t* out_height);
743
744/**
745 * Get all rendering geometry for a placement in a single call.
746 *
747 * Combines pixel size, grid size, viewport position, and source
748 * rectangle into one struct. Initialize with
749 * GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo).
750 *
751 * When viewport_visible is false, the placement is fully off-screen
752 * or is a virtual placement; viewport_col and viewport_row may
753 * contain meaningless values in that case.
754 *
755 * @param iterator The iterator positioned on a placement
756 * @param image The image handle for this placement's image
757 * @param terminal The terminal handle
758 * @param[out] out_info Pointer to receive the rendering geometry
759 * @return GHOSTTY_SUCCESS on success
760 *
761 * @ingroup kitty_graphics
762 */
763GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_render_info(
764 GhosttyKittyGraphicsPlacementIterator iterator,
765 GhosttyKittyGraphicsImage image,
766 GhosttyTerminal terminal,
767 GhosttyKittyGraphicsPlacementRenderInfo* out_info);
768
769/** @} */
770
771#ifdef __cplusplus
772}
773#endif
774
775#endif /* GHOSTTY_VT_KITTY_GRAPHICS_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/modes.h created+198
...@@ -0,0 +1,198 @@
1/**
2 * @file modes.h
3 *
4 * Terminal mode utilities - pack and unpack ANSI/DEC mode identifiers.
5 */
6
7#ifndef GHOSTTY_VT_MODES_H
8#define GHOSTTY_VT_MODES_H
9
10/** @defgroup modes Mode Utilities
11 *
12 * Utilities for working with terminal modes. A mode is a compact
13 * 16-bit representation of a terminal mode identifier that encodes both
14 * the numeric mode value (up to 15 bits) and whether the mode is an ANSI
15 * mode or a DEC private mode (?-prefixed).
16 *
17 * The packed layout (least-significant bit first) is:
18 * - Bits 0–14: mode value (u15)
19 * - Bit 15: ANSI flag (0 = DEC private mode, 1 = ANSI mode)
20 *
21 * ## Example
22 *
23 * @snippet c-vt-modes/src/main.c modes-pack-unpack
24 *
25 * ## DECRPM Report Encoding
26 *
27 * Use ghostty_mode_report_encode() to encode a DECRPM response into a
28 * caller-provided buffer:
29 *
30 * @snippet c-vt-modes/src/main.c modes-decrpm
31 *
32 * @{
33 */
34
35#include <stdbool.h>
36#include <stddef.h>
37#include <stdint.h>
38
39#include <ghostty/vt/types.h>
40
41#ifdef __cplusplus
42extern "C" {
43#endif
44
45/** @name ANSI Modes
46 * Modes for standard ANSI modes.
47 * @{
48 */
49#define GHOSTTY_MODE_KAM (ghostty_mode_new(2, true)) /**< Keyboard action (disable keyboard) */
50#define GHOSTTY_MODE_INSERT (ghostty_mode_new(4, true)) /**< Insert mode */
51#define GHOSTTY_MODE_SRM (ghostty_mode_new(12, true)) /**< Send/receive mode */
52#define GHOSTTY_MODE_LINEFEED (ghostty_mode_new(20, true)) /**< Linefeed/new line mode */
53/** @} */
54
55/** @name DEC Private Modes
56 * Modes for DEC private modes (?-prefixed).
57 * @{
58 */
59#define GHOSTTY_MODE_DECCKM (ghostty_mode_new(1, false)) /**< Cursor keys */
60#define GHOSTTY_MODE_132_COLUMN (ghostty_mode_new(3, false)) /**< 132/80 column mode */
61#define GHOSTTY_MODE_SLOW_SCROLL (ghostty_mode_new(4, false)) /**< Slow scroll */
62#define GHOSTTY_MODE_REVERSE_COLORS (ghostty_mode_new(5, false)) /**< Reverse video */
63#define GHOSTTY_MODE_ORIGIN (ghostty_mode_new(6, false)) /**< Origin mode */
64#define GHOSTTY_MODE_WRAPAROUND (ghostty_mode_new(7, false)) /**< Auto-wrap mode */
65#define GHOSTTY_MODE_AUTOREPEAT (ghostty_mode_new(8, false)) /**< Auto-repeat keys */
66#define GHOSTTY_MODE_X10_MOUSE (ghostty_mode_new(9, false)) /**< X10 mouse reporting */
67#define GHOSTTY_MODE_CURSOR_BLINKING (ghostty_mode_new(12, false)) /**< Cursor blink */
68#define GHOSTTY_MODE_CURSOR_VISIBLE (ghostty_mode_new(25, false)) /**< Cursor visible (DECTCEM) */
69#define GHOSTTY_MODE_ENABLE_MODE_3 (ghostty_mode_new(40, false)) /**< Allow 132 column mode */
70#define GHOSTTY_MODE_REVERSE_WRAP (ghostty_mode_new(45, false)) /**< Reverse wrap */
71#define GHOSTTY_MODE_ALT_SCREEN_LEGACY (ghostty_mode_new(47, false)) /**< Alternate screen (legacy) */
72#define GHOSTTY_MODE_KEYPAD_KEYS (ghostty_mode_new(66, false)) /**< Application keypad */
73#define GHOSTTY_MODE_BACKARROW_KEY_MODE (ghostty_mode_new(67, false)) /**< Backarrow key mode (DECBKM) */
74#define GHOSTTY_MODE_LEFT_RIGHT_MARGIN (ghostty_mode_new(69, false)) /**< Left/right margin mode */
75#define GHOSTTY_MODE_NORMAL_MOUSE (ghostty_mode_new(1000, false)) /**< Normal mouse tracking */
76#define GHOSTTY_MODE_BUTTON_MOUSE (ghostty_mode_new(1002, false)) /**< Button-event mouse tracking */
77#define GHOSTTY_MODE_ANY_MOUSE (ghostty_mode_new(1003, false)) /**< Any-event mouse tracking */
78#define GHOSTTY_MODE_FOCUS_EVENT (ghostty_mode_new(1004, false)) /**< Focus in/out events */
79#define GHOSTTY_MODE_UTF8_MOUSE (ghostty_mode_new(1005, false)) /**< UTF-8 mouse format */
80#define GHOSTTY_MODE_SGR_MOUSE (ghostty_mode_new(1006, false)) /**< SGR mouse format */
81#define GHOSTTY_MODE_ALT_SCROLL (ghostty_mode_new(1007, false)) /**< Alternate scroll mode */
82#define GHOSTTY_MODE_URXVT_MOUSE (ghostty_mode_new(1015, false)) /**< URxvt mouse format */
83#define GHOSTTY_MODE_SGR_PIXELS_MOUSE (ghostty_mode_new(1016, false)) /**< SGR-Pixels mouse format */
84#define GHOSTTY_MODE_NUMLOCK_KEYPAD (ghostty_mode_new(1035, false)) /**< Ignore keypad with NumLock */
85#define GHOSTTY_MODE_ALT_ESC_PREFIX (ghostty_mode_new(1036, false)) /**< Alt key sends ESC prefix */
86#define GHOSTTY_MODE_ALT_SENDS_ESC (ghostty_mode_new(1039, false)) /**< Alt sends escape */
87#define GHOSTTY_MODE_REVERSE_WRAP_EXT (ghostty_mode_new(1045, false)) /**< Extended reverse wrap */
88#define GHOSTTY_MODE_ALT_SCREEN (ghostty_mode_new(1047, false)) /**< Alternate screen */
89#define GHOSTTY_MODE_SAVE_CURSOR (ghostty_mode_new(1048, false)) /**< Save cursor (DECSC) */
90#define GHOSTTY_MODE_ALT_SCREEN_SAVE (ghostty_mode_new(1049, false)) /**< Alt screen + save cursor + clear */
91#define GHOSTTY_MODE_BRACKETED_PASTE (ghostty_mode_new(2004, false)) /**< Bracketed paste mode */
92#define GHOSTTY_MODE_SYNC_OUTPUT (ghostty_mode_new(2026, false)) /**< Synchronized output */
93#define GHOSTTY_MODE_GRAPHEME_CLUSTER (ghostty_mode_new(2027, false)) /**< Grapheme cluster mode */
94#define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */
95#define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */
96/** @} */
97
98/**
99 * A packed 16-bit terminal mode.
100 *
101 * Encodes a mode value (bits 0–14) and an ANSI flag (bit 15) into a
102 * single 16-bit integer. Use the inline helper functions to construct
103 * and inspect modes rather than manipulating bits directly.
104 */
105typedef uint16_t GhosttyMode;
106
107/**
108 * Create a mode from a mode value and ANSI flag.
109 *
110 * @param value The numeric mode value (0–32767)
111 * @param ansi true for an ANSI mode, false for a DEC private mode
112 * @return The packed mode
113 *
114 * @ingroup modes
115 */
116static inline GhosttyMode ghostty_mode_new(uint16_t value, bool ansi) {
117 return (GhosttyMode)((value & 0x7FFF) | ((uint16_t)ansi << 15));
118}
119
120/**
121 * Extract the numeric mode value from a mode.
122 *
123 * @param mode The mode
124 * @return The mode value (0–32767)
125 *
126 * @ingroup modes
127 */
128static inline uint16_t ghostty_mode_value(GhosttyMode mode) {
129 return mode & 0x7FFF;
130}
131
132/**
133 * Check whether a mode represents an ANSI mode.
134 *
135 * @param mode The mode
136 * @return true if this is an ANSI mode, false if it is a DEC private mode
137 *
138 * @ingroup modes
139 */
140static inline bool ghostty_mode_ansi(GhosttyMode mode) {
141 return (mode >> 15) != 0;
142}
143
144/**
145 * DECRPM report state values.
146 *
147 * These correspond to the Ps2 parameter in a DECRPM response
148 * sequence (CSI ? Ps1 ; Ps2 $ y).
149 */
150typedef enum GHOSTTY_ENUM_TYPED {
151 /** Mode is not recognized */
152 GHOSTTY_MODE_REPORT_NOT_RECOGNIZED = 0,
153 /** Mode is set (enabled) */
154 GHOSTTY_MODE_REPORT_SET = 1,
155 /** Mode is reset (disabled) */
156 GHOSTTY_MODE_REPORT_RESET = 2,
157 /** Mode is permanently set */
158 GHOSTTY_MODE_REPORT_PERMANENTLY_SET = 3,
159 /** Mode is permanently reset */
160 GHOSTTY_MODE_REPORT_PERMANENTLY_RESET = 4,
161 GHOSTTY_MODE_REPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
162} GhosttyModeReportState;
163
164/**
165 * Encode a DECRPM (DEC Private Mode Report) response sequence.
166 *
167 * Writes a mode report escape sequence into the provided buffer.
168 * The generated sequence has the form:
169 * - DEC private mode: CSI ? Ps1 ; Ps2 $ y
170 * - ANSI mode: CSI Ps1 ; Ps2 $ y
171 *
172 * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE
173 * and writes the required buffer size to @p out_written. The caller can
174 * then retry with a sufficiently sized buffer.
175 *
176 * @param mode The mode identifying the mode to report on
177 * @param state The report state for this mode
178 * @param buf Output buffer to write the encoded sequence into (may be NULL)
179 * @param buf_len Size of the output buffer in bytes
180 * @param[out] out_written On success, the number of bytes written. On
181 * GHOSTTY_OUT_OF_SPACE, the required buffer size.
182 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer
183 * is too small
184 */
185GHOSTTY_API GhosttyResult ghostty_mode_report_encode(
186 GhosttyMode mode,
187 GhosttyModeReportState state,
188 char* buf,
189 size_t buf_len,
190 size_t* out_written);
191
192#ifdef __cplusplus
193}
194#endif
195
196/** @} */
197
198#endif /* GHOSTTY_VT_MODES_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse.h created+70
...@@ -0,0 +1,70 @@
1/**
2 * @file mouse.h
3 *
4 * Mouse encoding module - encode mouse events into terminal escape sequences.
5 */
6
7#ifndef GHOSTTY_VT_MOUSE_H
8#define GHOSTTY_VT_MOUSE_H
9
10/** @defgroup mouse Mouse Encoding
11 *
12 * Utilities for encoding mouse events into terminal escape sequences,
13 * supporting X10, UTF-8, SGR, URxvt, and SGR-Pixels mouse protocols.
14 *
15 * ## Basic Usage
16 *
17 * 1. Create an encoder instance with ghostty_mouse_encoder_new().
18 * 2. Configure encoder options with ghostty_mouse_encoder_setopt() or
19 * ghostty_mouse_encoder_setopt_from_terminal().
20 * 3. For each mouse event:
21 * - Create a mouse event with ghostty_mouse_event_new().
22 * - Set event properties (action, button, modifiers, position).
23 * - Encode with ghostty_mouse_encoder_encode().
24 * - Free the event with ghostty_mouse_event_free() or reuse it.
25 * 4. Free the encoder with ghostty_mouse_encoder_free() when done.
26 *
27 * For a complete working example, see example/c-vt-encode-mouse in the
28 * repository.
29 *
30 * ## Example
31 *
32 * @snippet c-vt-encode-mouse/src/main.c mouse-encode
33 *
34 * ## Example: Encoding with Terminal State
35 *
36 * When you have a GhosttyTerminal, you can sync its tracking mode and
37 * output format into the encoder automatically:
38 *
39 * @code{.c}
40 * // Create a terminal and feed it some VT data that enables mouse tracking
41 * GhosttyTerminal terminal;
42 * ghostty_terminal_new(NULL, &terminal,
43 * (GhosttyTerminalOptions){.cols = 80, .rows = 24, .max_scrollback = 0});
44 *
45 * // Application might write data that enables mouse reporting, etc.
46 * ghostty_terminal_vt_write(terminal, vt_data, vt_len);
47 *
48 * // Create an encoder and sync its options from the terminal
49 * GhosttyMouseEncoder encoder;
50 * ghostty_mouse_encoder_new(NULL, &encoder);
51 * ghostty_mouse_encoder_setopt_from_terminal(encoder, terminal);
52 *
53 * // Encode a mouse event using the terminal-derived options
54 * char buf[128];
55 * size_t written = 0;
56 * ghostty_mouse_encoder_encode(encoder, event, buf, sizeof(buf), &written);
57 *
58 * ghostty_mouse_encoder_free(encoder);
59 * ghostty_terminal_free(terminal);
60 * @endcode
61 *
62 * @{
63 */
64
65#include <ghostty/vt/mouse/event.h>
66#include <ghostty/vt/mouse/encoder.h>
67
68/** @} */
69
70#endif /* GHOSTTY_VT_MOUSE_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/encoder.h created+214
...@@ -0,0 +1,214 @@
1/**
2 * @file encoder.h
3 *
4 * Mouse event encoding to terminal escape sequences.
5 */
6
7#ifndef GHOSTTY_VT_MOUSE_ENCODER_H
8#define GHOSTTY_VT_MOUSE_ENCODER_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/allocator.h>
14#include <ghostty/vt/mouse/event.h>
15#include <ghostty/vt/terminal.h>
16#include <ghostty/vt/types.h>
17
18/**
19 * Opaque handle to a mouse encoder instance.
20 *
21 * This handle represents a mouse encoder that converts normalized
22 * mouse events into terminal escape sequences.
23 *
24 * @ingroup mouse
25 */
26typedef struct GhosttyMouseEncoderImpl *GhosttyMouseEncoder;
27
28/**
29 * Mouse tracking mode.
30 *
31 * @ingroup mouse
32 */
33typedef enum GHOSTTY_ENUM_TYPED {
34 /** Mouse reporting disabled. */
35 GHOSTTY_MOUSE_TRACKING_NONE = 0,
36
37 /** X10 mouse mode. */
38 GHOSTTY_MOUSE_TRACKING_X10 = 1,
39
40 /** Normal mouse mode (button press/release only). */
41 GHOSTTY_MOUSE_TRACKING_NORMAL = 2,
42
43 /** Button-event tracking mode. */
44 GHOSTTY_MOUSE_TRACKING_BUTTON = 3,
45
46 /** Any-event tracking mode. */
47 GHOSTTY_MOUSE_TRACKING_ANY = 4,
48 GHOSTTY_MOUSE_TRACKING_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
49} GhosttyMouseTrackingMode;
50
51/**
52 * Mouse output format.
53 *
54 * @ingroup mouse
55 */
56typedef enum GHOSTTY_ENUM_TYPED {
57 GHOSTTY_MOUSE_FORMAT_X10 = 0,
58 GHOSTTY_MOUSE_FORMAT_UTF8 = 1,
59 GHOSTTY_MOUSE_FORMAT_SGR = 2,
60 GHOSTTY_MOUSE_FORMAT_URXVT = 3,
61 GHOSTTY_MOUSE_FORMAT_SGR_PIXELS = 4,
62 GHOSTTY_MOUSE_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
63} GhosttyMouseFormat;
64
65/**
66 * Mouse encoder size and geometry context.
67 *
68 * This describes the rendered terminal geometry used to convert
69 * surface-space positions into encoded coordinates.
70 *
71 * @ingroup mouse
72 */
73typedef struct {
74 /** Size of this struct in bytes. Must be set to sizeof(GhosttyMouseEncoderSize). */
75 size_t size;
76
77 /** Full screen width in pixels. */
78 uint32_t screen_width;
79
80 /** Full screen height in pixels. */
81 uint32_t screen_height;
82
83 /** Cell width in pixels. Must be non-zero. */
84 uint32_t cell_width;
85
86 /** Cell height in pixels. Must be non-zero. */
87 uint32_t cell_height;
88
89 /** Top padding in pixels. */
90 uint32_t padding_top;
91
92 /** Bottom padding in pixels. */
93 uint32_t padding_bottom;
94
95 /** Right padding in pixels. */
96 uint32_t padding_right;
97
98 /** Left padding in pixels. */
99 uint32_t padding_left;
100} GhosttyMouseEncoderSize;
101
102/**
103 * Mouse encoder option identifiers.
104 *
105 * These values are used with ghostty_mouse_encoder_setopt() to configure
106 * the behavior of the mouse encoder.
107 *
108 * @ingroup mouse
109 */
110typedef enum GHOSTTY_ENUM_TYPED {
111 /** Mouse tracking mode (value: GhosttyMouseTrackingMode). */
112 GHOSTTY_MOUSE_ENCODER_OPT_EVENT = 0,
113
114 /** Mouse output format (value: GhosttyMouseFormat). */
115 GHOSTTY_MOUSE_ENCODER_OPT_FORMAT = 1,
116
117 /** Renderer size context (value: GhosttyMouseEncoderSize). */
118 GHOSTTY_MOUSE_ENCODER_OPT_SIZE = 2,
119
120 /** Whether any mouse button is currently pressed (value: bool). */
121 GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED = 3,
122
123 /** Whether to enable motion deduplication by last cell (value: bool). */
124 GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL = 4,
125 GHOSTTY_MOUSE_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
126} GhosttyMouseEncoderOption;
127
128/**
129 * Create a new mouse encoder instance.
130 *
131 * @param allocator Pointer to allocator, or NULL to use the default allocator
132 * @param encoder Pointer to store the created encoder handle
133 * @return GHOSTTY_SUCCESS on success, or an error code on failure
134 *
135 * @ingroup mouse
136 */
137GHOSTTY_API GhosttyResult ghostty_mouse_encoder_new(const GhosttyAllocator *allocator,
138 GhosttyMouseEncoder *encoder);
139
140/**
141 * Free a mouse encoder instance.
142 *
143 * @param encoder The encoder handle to free (may be NULL)
144 *
145 * @ingroup mouse
146 */
147GHOSTTY_API void ghostty_mouse_encoder_free(GhosttyMouseEncoder encoder);
148
149/**
150 * Set an option on the mouse encoder.
151 *
152 * A null pointer value does nothing. It does not reset to defaults.
153 *
154 * @param encoder The encoder handle, must not be NULL
155 * @param option The option to set
156 * @param value Pointer to option value (type depends on option)
157 *
158 * @ingroup mouse
159 */
160GHOSTTY_API void ghostty_mouse_encoder_setopt(GhosttyMouseEncoder encoder,
161 GhosttyMouseEncoderOption option,
162 const void *value);
163
164/**
165 * Set encoder options from a terminal's current state.
166 *
167 * This sets tracking mode and output format from terminal state.
168 * It does not modify size or any-button state.
169 *
170 * @param encoder The encoder handle, must not be NULL
171 * @param terminal The terminal handle, must not be NULL
172 *
173 * @ingroup mouse
174 */
175GHOSTTY_API void ghostty_mouse_encoder_setopt_from_terminal(GhosttyMouseEncoder encoder,
176 GhosttyTerminal terminal);
177
178/**
179 * Reset internal encoder state.
180 *
181 * This clears motion deduplication state (last tracked cell).
182 *
183 * @param encoder The encoder handle (may be NULL)
184 *
185 * @ingroup mouse
186 */
187GHOSTTY_API void ghostty_mouse_encoder_reset(GhosttyMouseEncoder encoder);
188
189/**
190 * Encode a mouse event into a terminal escape sequence.
191 *
192 * Not all mouse events produce output. In such cases this returns
193 * GHOSTTY_SUCCESS with out_len set to 0.
194 *
195 * If the output buffer is too small, this returns GHOSTTY_OUT_OF_SPACE
196 * and out_len contains the required size.
197 *
198 * @param encoder The encoder handle, must not be NULL
199 * @param event The mouse event to encode, must not be NULL
200 * @param out_buf Buffer to write encoded bytes to, or NULL to query required size
201 * @param out_buf_size Size of out_buf in bytes
202 * @param out_len Pointer to store bytes written (or required bytes on failure)
203 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer is too small,
204 * or another error code
205 *
206 * @ingroup mouse
207 */
208GHOSTTY_API GhosttyResult ghostty_mouse_encoder_encode(GhosttyMouseEncoder encoder,
209 GhosttyMouseEvent event,
210 char *out_buf,
211 size_t out_buf_size,
212 size_t *out_len);
213
214#endif /* GHOSTTY_VT_MOUSE_ENCODER_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/event.h created+195
...@@ -0,0 +1,195 @@
1/**
2 * @file event.h
3 *
4 * Mouse event representation and manipulation.
5 */
6
7#ifndef GHOSTTY_VT_MOUSE_EVENT_H
8#define GHOSTTY_VT_MOUSE_EVENT_H
9
10#include <stdbool.h>
11#include <ghostty/vt/allocator.h>
12#include <ghostty/vt/key/event.h>
13#include <ghostty/vt/types.h>
14
15/**
16 * Opaque handle to a mouse event.
17 *
18 * This handle represents a normalized mouse input event containing
19 * action, button, modifiers, and surface-space position.
20 *
21 * @ingroup mouse
22 */
23typedef struct GhosttyMouseEventImpl *GhosttyMouseEvent;
24
25/**
26 * Mouse event action type.
27 *
28 * @ingroup mouse
29 */
30typedef enum GHOSTTY_ENUM_TYPED {
31 /** Mouse button was pressed. */
32 GHOSTTY_MOUSE_ACTION_PRESS = 0,
33
34 /** Mouse button was released. */
35 GHOSTTY_MOUSE_ACTION_RELEASE = 1,
36
37 /** Mouse moved. */
38 GHOSTTY_MOUSE_ACTION_MOTION = 2,
39 GHOSTTY_MOUSE_ACTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
40} GhosttyMouseAction;
41
42/**
43 * Mouse button identity.
44 *
45 * @ingroup mouse
46 */
47typedef enum GHOSTTY_ENUM_TYPED {
48 GHOSTTY_MOUSE_BUTTON_UNKNOWN = 0,
49 GHOSTTY_MOUSE_BUTTON_LEFT = 1,
50 GHOSTTY_MOUSE_BUTTON_RIGHT = 2,
51 GHOSTTY_MOUSE_BUTTON_MIDDLE = 3,
52 GHOSTTY_MOUSE_BUTTON_FOUR = 4,
53 GHOSTTY_MOUSE_BUTTON_FIVE = 5,
54 GHOSTTY_MOUSE_BUTTON_SIX = 6,
55 GHOSTTY_MOUSE_BUTTON_SEVEN = 7,
56 GHOSTTY_MOUSE_BUTTON_EIGHT = 8,
57 GHOSTTY_MOUSE_BUTTON_NINE = 9,
58 GHOSTTY_MOUSE_BUTTON_TEN = 10,
59 GHOSTTY_MOUSE_BUTTON_ELEVEN = 11,
60 GHOSTTY_MOUSE_BUTTON_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
61} GhosttyMouseButton;
62
63/**
64 * Mouse position in surface-space pixels.
65 *
66 * @ingroup mouse
67 */
68typedef struct {
69 float x;
70 float y;
71} GhosttyMousePosition;
72
73/**
74 * Create a new mouse event instance.
75 *
76 * @param allocator Pointer to allocator, or NULL to use the default allocator
77 * @param event Pointer to store the created event handle
78 * @return GHOSTTY_SUCCESS on success, or an error code on failure
79 *
80 * @ingroup mouse
81 */
82GHOSTTY_API GhosttyResult ghostty_mouse_event_new(const GhosttyAllocator *allocator,
83 GhosttyMouseEvent *event);
84
85/**
86 * Free a mouse event instance.
87 *
88 * @param event The mouse event handle to free (may be NULL)
89 *
90 * @ingroup mouse
91 */
92GHOSTTY_API void ghostty_mouse_event_free(GhosttyMouseEvent event);
93
94/**
95 * Set the event action.
96 *
97 * @param event The event handle, must not be NULL
98 * @param action The action to set
99 *
100 * @ingroup mouse
101 */
102GHOSTTY_API void ghostty_mouse_event_set_action(GhosttyMouseEvent event,
103 GhosttyMouseAction action);
104
105/**
106 * Get the event action.
107 *
108 * @param event The event handle, must not be NULL
109 * @return The event action
110 *
111 * @ingroup mouse
112 */
113GHOSTTY_API GhosttyMouseAction ghostty_mouse_event_get_action(GhosttyMouseEvent event);
114
115/**
116 * Set the event button.
117 *
118 * This sets a concrete button identity for the event.
119 * To represent "no button" (for motion events), use
120 * ghostty_mouse_event_clear_button().
121 *
122 * @param event The event handle, must not be NULL
123 * @param button The button to set
124 *
125 * @ingroup mouse
126 */
127GHOSTTY_API void ghostty_mouse_event_set_button(GhosttyMouseEvent event,
128 GhosttyMouseButton button);
129
130/**
131 * Clear the event button.
132 *
133 * This sets the event button to "none".
134 *
135 * @param event The event handle, must not be NULL
136 *
137 * @ingroup mouse
138 */
139GHOSTTY_API void ghostty_mouse_event_clear_button(GhosttyMouseEvent event);
140
141/**
142 * Get the event button.
143 *
144 * @param event The event handle, must not be NULL
145 * @param out_button Output pointer for the button value (may be NULL)
146 * @return true if a button is set, false if no button is set
147 *
148 * @ingroup mouse
149 */
150GHOSTTY_API bool ghostty_mouse_event_get_button(GhosttyMouseEvent event,
151 GhosttyMouseButton *out_button);
152
153/**
154 * Set keyboard modifiers held during the event.
155 *
156 * @param event The event handle, must not be NULL
157 * @param mods Modifier bitmask
158 *
159 * @ingroup mouse
160 */
161GHOSTTY_API void ghostty_mouse_event_set_mods(GhosttyMouseEvent event,
162 GhosttyMods mods);
163
164/**
165 * Get keyboard modifiers held during the event.
166 *
167 * @param event The event handle, must not be NULL
168 * @return Modifier bitmask
169 *
170 * @ingroup mouse
171 */
172GHOSTTY_API GhosttyMods ghostty_mouse_event_get_mods(GhosttyMouseEvent event);
173
174/**
175 * Set the event position in surface-space pixels.
176 *
177 * @param event The event handle, must not be NULL
178 * @param position The position to set
179 *
180 * @ingroup mouse
181 */
182GHOSTTY_API void ghostty_mouse_event_set_position(GhosttyMouseEvent event,
183 GhosttyMousePosition position);
184
185/**
186 * Get the event position in surface-space pixels.
187 *
188 * @param event The event handle, must not be NULL
189 * @return The current event position
190 *
191 * @ingroup mouse
192 */
193GHOSTTY_API GhosttyMousePosition ghostty_mouse_event_get_position(GhosttyMouseEvent event);
194
195#endif /* GHOSTTY_VT_MOUSE_EVENT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/osc.h created+215
...@@ -0,0 +1,215 @@
1/**
2 * @file osc.h
3 *
4 * OSC (Operating System Command) sequence parser and command handling.
5 */
6
7#ifndef GHOSTTY_VT_OSC_H
8#define GHOSTTY_VT_OSC_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/types.h>
14#include <ghostty/vt/allocator.h>
15
16/** @defgroup osc OSC Parser
17 *
18 * OSC (Operating System Command) sequence parser and command handling.
19 *
20 * The parser operates in a streaming fashion, processing input byte-by-byte
21 * to handle OSC sequences that may arrive in fragments across multiple reads.
22 * This interface makes it easy to integrate into most environments and avoids
23 * over-allocating buffers.
24 *
25 * ## Basic Usage
26 *
27 * 1. Create a parser instance with ghostty_osc_new()
28 * 2. Feed bytes to the parser using ghostty_osc_next()
29 * 3. Finalize parsing with ghostty_osc_end() to get the command
30 * 4. Query command type and extract data using ghostty_osc_command_type()
31 * and ghostty_osc_command_data()
32 * 5. Free the parser with ghostty_osc_free() when done
33 *
34 * @{
35 */
36
37/**
38 * OSC command types.
39 *
40 * @ingroup osc
41 */
42typedef enum GHOSTTY_ENUM_TYPED {
43 GHOSTTY_OSC_COMMAND_INVALID = 0,
44 GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1,
45 GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2,
46 GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3,
47 GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4,
48 GHOSTTY_OSC_COMMAND_REPORT_PWD = 5,
49 GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6,
50 GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7,
51 GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8,
52 GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9,
53 GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10,
54 GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11,
55 GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12,
56 GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13,
57 GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14,
58 GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15,
59 GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16,
60 GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17,
61 GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18,
62 GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19,
63 GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20,
64 GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21,
65 GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22,
66 GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
67} GhosttyOscCommandType;
68
69/**
70 * OSC command data types.
71 *
72 * These values specify what type of data to extract from an OSC command
73 * using `ghostty_osc_command_data`.
74 *
75 * @ingroup osc
76 */
77typedef enum GHOSTTY_ENUM_TYPED {
78 /** Invalid data type. Never results in any data extraction. */
79 GHOSTTY_OSC_DATA_INVALID = 0,
80
81 /**
82 * Window title string data.
83 *
84 * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE
85 *
86 * Output type: const char ** (pointer to null-terminated string)
87 *
88 * Lifetime: Valid until the next call to any ghostty_osc_* function with
89 * the same parser instance. Memory is owned by the parser.
90 */
91 GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1,
92 GHOSTTY_OSC_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
93} GhosttyOscCommandData;
94
95/**
96 * Create a new OSC parser instance.
97 *
98 * Creates a new OSC (Operating System Command) parser using the provided
99 * allocator. The parser must be freed using ghostty_vt_osc_free() when
100 * no longer needed.
101 *
102 * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
103 * @param parser Pointer to store the created parser handle
104 * @return GHOSTTY_SUCCESS on success, or an error code on failure
105 *
106 * @ingroup osc
107 */
108GHOSTTY_API GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser);
109
110/**
111 * Free an OSC parser instance.
112 *
113 * Releases all resources associated with the OSC parser. After this call,
114 * the parser handle becomes invalid and must not be used.
115 *
116 * @param parser The parser handle to free (may be NULL)
117 *
118 * @ingroup osc
119 */
120GHOSTTY_API void ghostty_osc_free(GhosttyOscParser parser);
121
122/**
123 * Reset an OSC parser instance to its initial state.
124 *
125 * Resets the parser state, clearing any partially parsed OSC sequences
126 * and returning the parser to its initial state. This is useful for
127 * reusing a parser instance or recovering from parse errors.
128 *
129 * @param parser The parser handle to reset, must not be null.
130 *
131 * @ingroup osc
132 */
133GHOSTTY_API void ghostty_osc_reset(GhosttyOscParser parser);
134
135/**
136 * Parse the next byte in an OSC sequence.
137 *
138 * Processes a single byte as part of an OSC sequence. The parser maintains
139 * internal state to track the progress through the sequence. Call this
140 * function for each byte in the sequence data.
141 *
142 * When finished pumping the parser with bytes, call ghostty_osc_end
143 * to get the final result.
144 *
145 * @param parser The parser handle, must not be null.
146 * @param byte The next byte to parse
147 *
148 * @ingroup osc
149 */
150GHOSTTY_API void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte);
151
152/**
153 * Finalize OSC parsing and retrieve the parsed command.
154 *
155 * Call this function after feeding all bytes of an OSC sequence to the parser
156 * using ghostty_osc_next() with the exception of the terminating character
157 * (ESC or ST). This function finalizes the parsing process and returns the
158 * parsed OSC command.
159 *
160 * The return value is never NULL. Invalid commands will return a command
161 * with type GHOSTTY_OSC_COMMAND_INVALID.
162 *
163 * The terminator parameter specifies the byte that terminated the OSC sequence
164 * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is
165 * preserved in the parsed command so that responses can use the same terminator
166 * format for better compatibility with the calling program. For commands that
167 * do not require a response, this parameter is ignored and the resulting
168 * command will not retain the terminator information.
169 *
170 * The returned command handle is valid until the next call to any
171 * `ghostty_osc_*` function with the same parser instance with the exception
172 * of command introspection functions such as `ghostty_osc_command_type`.
173 *
174 * @param parser The parser handle, must not be null.
175 * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST)
176 * @return Handle to the parsed OSC command
177 *
178 * @ingroup osc
179 */
180GHOSTTY_API GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator);
181
182/**
183 * Get the type of an OSC command.
184 *
185 * Returns the type identifier for the given OSC command. This can be used
186 * to determine what kind of command was parsed and what data might be
187 * available from it.
188 *
189 * @param command The OSC command handle to query (may be NULL)
190 * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL
191 *
192 * @ingroup osc
193 */
194GHOSTTY_API GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command);
195
196/**
197 * Extract data from an OSC command.
198 *
199 * Extracts typed data from the given OSC command based on the specified
200 * data type. The output pointer must be of the appropriate type for the
201 * requested data kind. Valid command types, output types, and memory
202 * safety information are documented in the `GhosttyOscCommandData` enum.
203 *
204 * @param command The OSC command handle to query (may be NULL)
205 * @param data The type of data to extract
206 * @param out Pointer to store the extracted data (type depends on data parameter)
207 * @return true if data extraction was successful, false otherwise
208 *
209 * @ingroup osc
210 */
211GHOSTTY_API bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out);
212
213/** @} */
214
215#endif /* GHOSTTY_VT_OSC_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/paste.h created+101
...@@ -0,0 +1,101 @@
1/**
2 * @file paste.h
3 *
4 * Paste utilities - validate and encode paste data for terminal input.
5 */
6
7#ifndef GHOSTTY_VT_PASTE_H
8#define GHOSTTY_VT_PASTE_H
9
10/** @defgroup paste Paste Utilities
11 *
12 * Utilities for validating and encoding paste data for terminal input.
13 *
14 * ## Basic Usage
15 *
16 * Use ghostty_paste_is_safe() to check if paste data contains potentially
17 * dangerous sequences before sending it to the terminal.
18 *
19 * Use ghostty_paste_encode() to encode paste data for writing to the pty,
20 * including bracketed paste wrapping and unsafe byte stripping.
21 *
22 * ## Examples
23 *
24 * ### Safety Check
25 *
26 * @snippet c-vt-paste/src/main.c paste-safety
27 *
28 * ### Encoding
29 *
30 * @snippet c-vt-paste/src/main.c paste-encode
31 *
32 * @{
33 */
34
35#include <stdbool.h>
36#include <stddef.h>
37#include <ghostty/vt/types.h>
38
39#ifdef __cplusplus
40extern "C" {
41#endif
42
43/**
44 * Check if paste data is safe to paste into the terminal.
45 *
46 * Data is considered unsafe if it contains:
47 * - Newlines (`\n`) which can inject commands
48 * - The bracketed paste end sequence (`\x1b[201~`) which can be used
49 * to exit bracketed paste mode and inject commands
50 *
51 * This check is conservative and considers data unsafe regardless of
52 * current terminal state.
53 *
54 * @param data The paste data to check (must not be NULL)
55 * @param len The length of the data in bytes
56 * @return true if the data is safe to paste, false otherwise
57 */
58GHOSTTY_API bool ghostty_paste_is_safe(const char* data, size_t len);
59
60/**
61 * Encode paste data for writing to the terminal pty.
62 *
63 * This function prepares paste data for terminal input by:
64 * - Stripping unsafe control bytes (NUL, ESC, DEL, etc.) by replacing
65 * them with spaces
66 * - Wrapping the data in bracketed paste sequences if @p bracketed is true
67 * - Replacing newlines with carriage returns if @p bracketed is false
68 *
69 * The input @p data buffer is modified in place during encoding. The
70 * encoded result (potentially with bracketed paste prefix/suffix) is
71 * written to the output buffer.
72 *
73 * If the output buffer is too small, the function returns
74 * GHOSTTY_OUT_OF_SPACE and sets the required size in @p out_written.
75 * The caller can then retry with a sufficiently sized buffer.
76 *
77 * @param data The paste data to encode (modified in place, may be NULL)
78 * @param data_len The length of the input data in bytes
79 * @param bracketed Whether bracketed paste mode is active
80 * @param buf Output buffer to write the encoded result into (may be NULL)
81 * @param buf_len Size of the output buffer in bytes
82 * @param[out] out_written On success, the number of bytes written. On
83 * GHOSTTY_OUT_OF_SPACE, the required buffer size.
84 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer
85 * is too small
86 */
87GHOSTTY_API GhosttyResult ghostty_paste_encode(
88 char* data,
89 size_t data_len,
90 bool bracketed,
91 char* buf,
92 size_t buf_len,
93 size_t* out_written);
94
95#ifdef __cplusplus
96}
97#endif
98
99/** @} */
100
101#endif /* GHOSTTY_VT_PASTE_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/point.h created+89
...@@ -0,0 +1,89 @@
1/**
2 * @file point.h
3 *
4 * Terminal point types for referencing locations in the terminal grid.
5 */
6
7#ifndef GHOSTTY_VT_POINT_H
8#define GHOSTTY_VT_POINT_H
9
10#include <stdint.h>
11
12#ifdef __cplusplus
13extern "C" {
14#endif
15
16/** @defgroup point Point
17 *
18 * Types for referencing x/y positions in the terminal grid under
19 * different coordinate systems (active area, viewport, full screen,
20 * scrollback history).
21 *
22 * @{
23 */
24
25/**
26 * A coordinate in the terminal grid.
27 *
28 * @ingroup point
29 */
30typedef struct {
31 /** Column (0-indexed). */
32 uint16_t x;
33
34 /** Row (0-indexed). May exceed page size for screen/history tags. */
35 uint32_t y;
36} GhosttyPointCoordinate;
37
38/**
39 * Point reference tag.
40 *
41 * Determines which coordinate system a point uses.
42 *
43 * @ingroup point
44 */
45typedef enum GHOSTTY_ENUM_TYPED {
46 /** Active area where the cursor can move. */
47 GHOSTTY_POINT_TAG_ACTIVE = 0,
48
49 /** Visible viewport (changes when scrolled). */
50 GHOSTTY_POINT_TAG_VIEWPORT = 1,
51
52 /** Full screen including scrollback. */
53 GHOSTTY_POINT_TAG_SCREEN = 2,
54
55 /** Scrollback history only (before active area). */
56 GHOSTTY_POINT_TAG_HISTORY = 3,
57 GHOSTTY_POINT_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
58 } GhosttyPointTag;
59
60/**
61 * Point value union.
62 *
63 * @ingroup point
64 */
65typedef union {
66 /** Coordinate (used for all tag variants). */
67 GhosttyPointCoordinate coordinate;
68
69 /** Padding for ABI compatibility. Do not use. */
70 uint64_t _padding[2];
71} GhosttyPointValue;
72
73/**
74 * Tagged union for a point in the terminal grid.
75 *
76 * @ingroup point
77 */
78typedef struct {
79 GhosttyPointTag tag;
80 GhosttyPointValue value;
81} GhosttyPoint;
82
83/** @} */
84
85#ifdef __cplusplus
86}
87#endif
88
89#endif /* GHOSTTY_VT_POINT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/render.h created+729
...@@ -0,0 +1,729 @@
1/**
2 * @file render.h
3 *
4 * Render state for creating high performance renderers.
5 */
6
7#ifndef GHOSTTY_VT_RENDER_H
8#define GHOSTTY_VT_RENDER_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/allocator.h>
14#include <ghostty/vt/color.h>
15#include <ghostty/vt/terminal.h>
16#include <ghostty/vt/types.h>
17
18#ifdef __cplusplus
19extern "C" {
20#endif
21
22/** @defgroup render Render State
23 *
24 * Represents the state required to render a visible screen (a viewport)
25 * of a terminal instance. This is stateful and optimized for repeated
26 * updates from a single terminal instance and only updating dirty regions
27 * of the screen.
28 *
29 * The key design principle of this API is that it only needs read/write
30 * access to the terminal instance during the update call. This allows
31 * the render state to minimally impact terminal IO performance and also
32 * allows the renderer to be safely multi-threaded (as long as a lock is
33 * held during the update call to ensure exclusive access to the terminal
34 * instance).
35 *
36 * The basic usage of this API is:
37 *
38 * 1. Create an empty render state
39 * 2. Update it from a terminal instance whenever you need.
40 * 3. Read from the render state to get the data needed to draw your frame.
41 *
42 * ## Dirty Tracking
43 *
44 * Dirty tracking is a key feature of the render state that allows renderers
45 * to efficiently determine what parts of the screen have changed and only
46 * redraw changed regions.
47 *
48 * The render state API keeps track of dirty state at two independent layers:
49 * a global dirty state that indicates whether the entire frame is clean,
50 * partially dirty, or fully dirty, and a per-row dirty state that allows
51 * tracking which rows in a partially dirty frame have changed.
52 *
53 * The user of the render state API is expected to unset both of these.
54 * The `update` call does not unset dirty state, it only updates it.
55 *
56 * An extremely important detail: setting one dirty state doesn't unset
57 * the other. For example, setting the global dirty state to false does not
58 * reset the row-level dirty flags. So, the caller of the render state API must
59 * be careful to manage both layers of dirty state correctly.
60 *
61 * ## Examples
62 *
63 * ### Creating and updating render state
64 * @snippet c-vt-render/src/main.c render-state-update
65 *
66 * ### Checking dirty state
67 * @snippet c-vt-render/src/main.c render-dirty-check
68 *
69 * ### Reading colors
70 * @snippet c-vt-render/src/main.c render-colors
71 *
72 * ### Reading cursor state
73 * @snippet c-vt-render/src/main.c render-cursor
74 *
75 * ### Iterating rows and cells
76 * @snippet c-vt-render/src/main.c render-row-iterate
77 *
78 * ### Resetting dirty state after rendering
79 * @snippet c-vt-render/src/main.c render-dirty-reset
80 *
81 * @{
82 */
83
84/**
85 * Dirty state of a render state after update.
86 *
87 * @ingroup render
88 */
89typedef enum GHOSTTY_ENUM_TYPED {
90 /** Not dirty at all; rendering can be skipped. */
91 GHOSTTY_RENDER_STATE_DIRTY_FALSE = 0,
92
93 /** Some rows changed; renderer can redraw incrementally. */
94 GHOSTTY_RENDER_STATE_DIRTY_PARTIAL = 1,
95
96 /** Global state changed; renderer should redraw everything. */
97 GHOSTTY_RENDER_STATE_DIRTY_FULL = 2,
98 GHOSTTY_RENDER_STATE_DIRTY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
99} GhosttyRenderStateDirty;
100
101/**
102 * Visual style of the cursor.
103 *
104 * @ingroup render
105 */
106typedef enum GHOSTTY_ENUM_TYPED {
107 /** Bar cursor (DECSCUSR 5, 6). */
108 GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BAR = 0,
109
110 /** Block cursor (DECSCUSR 1, 2). */
111 GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK = 1,
112
113 /** Underline cursor (DECSCUSR 3, 4). */
114 GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_UNDERLINE = 2,
115
116 /** Hollow block cursor. */
117 GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK_HOLLOW = 3,
118 GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
119} GhosttyRenderStateCursorVisualStyle;
120
121/**
122 * Queryable data kinds for ghostty_render_state_get().
123 *
124 * @ingroup render
125 */
126typedef enum GHOSTTY_ENUM_TYPED {
127 /** Invalid / sentinel value. */
128 GHOSTTY_RENDER_STATE_DATA_INVALID = 0,
129
130 /** Viewport width in cells (uint16_t). */
131 GHOSTTY_RENDER_STATE_DATA_COLS = 1,
132
133 /** Viewport height in cells (uint16_t). */
134 GHOSTTY_RENDER_STATE_DATA_ROWS = 2,
135
136 /** Current dirty state (GhosttyRenderStateDirty). */
137 GHOSTTY_RENDER_STATE_DATA_DIRTY = 3,
138
139 /** Populate a pre-allocated GhosttyRenderStateRowIterator with row data
140 * from the render state (GhosttyRenderStateRowIterator). Row data is
141 * only valid as long as the underlying render state is not updated.
142 * It is unsafe to use row data after updating the render state.
143 * */
144 GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR = 4,
145
146 /** Default/current background color (GhosttyColorRgb). */
147 GHOSTTY_RENDER_STATE_DATA_COLOR_BACKGROUND = 5,
148
149 /** Default/current foreground color (GhosttyColorRgb). */
150 GHOSTTY_RENDER_STATE_DATA_COLOR_FOREGROUND = 6,
151
152 /** Cursor color when explicitly set by terminal state (GhosttyColorRgb).
153 * Returns GHOSTTY_INVALID_VALUE if no explicit cursor color is set;
154 * use COLOR_CURSOR_HAS_VALUE to check first. */
155 GHOSTTY_RENDER_STATE_DATA_COLOR_CURSOR = 7,
156
157 /** Whether an explicit cursor color is set (bool). */
158 GHOSTTY_RENDER_STATE_DATA_COLOR_CURSOR_HAS_VALUE = 8,
159
160 /** The active 256-color palette (GhosttyColorRgb[256]). */
161 GHOSTTY_RENDER_STATE_DATA_COLOR_PALETTE = 9,
162
163 /** The visual style of the cursor (GhosttyRenderStateCursorVisualStyle). */
164 GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE = 10,
165
166 /** Whether the cursor is visible based on terminal modes (bool). */
167 GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE = 11,
168
169 /** Whether the cursor should blink based on terminal modes (bool). */
170 GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING = 12,
171
172 /** Whether the cursor is at a password input field (bool). */
173 GHOSTTY_RENDER_STATE_DATA_CURSOR_PASSWORD_INPUT = 13,
174
175 /** Whether the cursor is visible within the viewport (bool).
176 * If false, the cursor viewport position values are undefined. */
177 GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE = 14,
178
179 /** Cursor viewport x position in cells (uint16_t).
180 * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */
181 GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X = 15,
182
183 /** Cursor viewport y position in cells (uint16_t).
184 * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */
185 GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y = 16,
186
187 /** Whether the cursor is on the tail of a wide character (bool).
188 * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */
189 GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL = 17,
190 GHOSTTY_RENDER_STATE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
191} GhosttyRenderStateData;
192
193/**
194 * Settable options for ghostty_render_state_set().
195 *
196 * @ingroup render
197 */
198typedef enum GHOSTTY_ENUM_TYPED {
199 /** Set dirty state (GhosttyRenderStateDirty). */
200 GHOSTTY_RENDER_STATE_OPTION_DIRTY = 0,
201 GHOSTTY_RENDER_STATE_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
202} GhosttyRenderStateOption;
203
204/**
205 * Queryable data kinds for ghostty_render_state_row_get().
206 *
207 * @ingroup render
208 */
209typedef enum GHOSTTY_ENUM_TYPED {
210 /** Invalid / sentinel value. */
211 GHOSTTY_RENDER_STATE_ROW_DATA_INVALID = 0,
212
213 /** Whether the current row is dirty (bool). */
214 GHOSTTY_RENDER_STATE_ROW_DATA_DIRTY = 1,
215
216 /** The raw row value (GhosttyRow). */
217 GHOSTTY_RENDER_STATE_ROW_DATA_RAW = 2,
218
219 /** Populate a pre-allocated GhosttyRenderStateRowCells with cell data for
220 * the current row (GhosttyRenderStateRowCells). Cell data is only
221 * valid as long as the underlying render state is not updated.
222 * It is unsafe to use cell data after updating the render state. */
223 GHOSTTY_RENDER_STATE_ROW_DATA_CELLS = 3,
224
225 /** Row-local selected cell range (GhosttyRenderStateRowSelection). */
226 GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4,
227 GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
228} GhosttyRenderStateRowData;
229
230/**
231 * Settable options for ghostty_render_state_row_set().
232 *
233 * @ingroup render
234 */
235typedef enum GHOSTTY_ENUM_TYPED {
236 /** Set dirty state for the current row (bool). */
237 GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY = 0,
238 GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
239} GhosttyRenderStateRowOption;
240
241/**
242 * Row-local selection range.
243 *
244 * This struct uses the sized-struct ABI pattern. Initialize with
245 * GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection) before querying
246 * GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION.
247 *
248 * Querying GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION returns GHOSTTY_NO_VALUE
249 * if the current row does not intersect the current selection.
250 *
251 * @ingroup render
252 */
253typedef struct {
254 /** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateRowSelection). */
255 size_t size;
256
257 /** Start column of the row-local selection range, inclusive. */
258 uint16_t start_x;
259
260 /** End column of the row-local selection range, inclusive. */
261 uint16_t end_x;
262} GhosttyRenderStateRowSelection;
263
264/**
265 * Render-state color information.
266 *
267 * This struct uses the sized-struct ABI pattern. Initialize with
268 * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before calling
269 * ghostty_render_state_colors_get().
270 *
271 * Example:
272 * @code
273 * GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors);
274 * GhosttyResult result = ghostty_render_state_colors_get(state, &colors);
275 * @endcode
276 *
277 * @ingroup render
278 */
279typedef struct {
280 /** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateColors). */
281 size_t size;
282
283 /** The default/current background color for the render state. */
284 GhosttyColorRgb background;
285
286 /** The default/current foreground color for the render state. */
287 GhosttyColorRgb foreground;
288
289 /** The cursor color when explicitly set by terminal state. */
290 GhosttyColorRgb cursor;
291
292 /**
293 * True when cursor contains a valid explicit cursor color value.
294 * If this is false, the cursor color should be ignored; it will
295 * contain undefined data.
296 * */
297 bool cursor_has_value;
298
299 /** The active 256-color palette for this render state. */
300 GhosttyColorRgb palette[256];
301} GhosttyRenderStateColors;
302
303/**
304 * Create a new render state instance.
305 *
306 * @param allocator Pointer to allocator, or NULL to use the default allocator
307 * @param state Pointer to store the created render state handle
308 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation
309 * failure
310 *
311 * @ingroup render
312 */
313GHOSTTY_API GhosttyResult ghostty_render_state_new(const GhosttyAllocator* allocator,
314 GhosttyRenderState* state);
315
316/**
317 * Free a render state instance.
318 *
319 * Releases all resources associated with the render state. After this call,
320 * the render state handle becomes invalid.
321 *
322 * @param state The render state handle to free (may be NULL)
323 *
324 * @ingroup render
325 */
326GHOSTTY_API void ghostty_render_state_free(GhosttyRenderState state);
327
328/**
329 * Update a render state instance from a terminal.
330 *
331 * This consumes terminal/screen dirty state in the same way as the internal
332 * render state update path.
333 *
334 * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
335 * @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE)
336 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or
337 * `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires
338 * allocation and that allocation fails
339 *
340 * @ingroup render
341 */
342GHOSTTY_API GhosttyResult ghostty_render_state_update(GhosttyRenderState state,
343 GhosttyTerminal terminal);
344
345/**
346 * Get a value from a render state.
347 *
348 * The `out` pointer must point to a value of the type corresponding to the
349 * requested data kind (see GhosttyRenderStateData).
350 *
351 * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
352 * @param data The data kind to query
353 * @param[out] out Pointer to receive the queried value
354 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is
355 * NULL or `data` is not a recognized enum value
356 *
357 * @ingroup render
358 */
359GHOSTTY_API GhosttyResult ghostty_render_state_get(GhosttyRenderState state,
360 GhosttyRenderStateData data,
361 void* out);
362
363/**
364 * Get multiple data fields from a render state in a single call.
365 *
366 * Each element in the keys array specifies a data kind, and the
367 * corresponding element in the values array receives the result.
368 *
369 * Processing stops at the first error; on success out_written
370 * is set to count, on error it is set to the index of the
371 * failing key (i.e. the number of values successfully written).
372 *
373 * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
374 * @param count Number of key/value pairs
375 * @param keys Array of data kinds to query
376 * @param values Array of output pointers (types must match each key's
377 * documented output type)
378 * @param[out] out_written On return, receives the number of values
379 * successfully written (may be NULL)
380 * @return GHOSTTY_SUCCESS if all queries succeed
381 *
382 * @ingroup render
383 */
384GHOSTTY_API GhosttyResult ghostty_render_state_get_multi(
385 GhosttyRenderState state,
386 size_t count,
387 const GhosttyRenderStateData* keys,
388 void** values,
389 size_t* out_written);
390
391/**
392 * Set an option on a render state.
393 *
394 * The `value` pointer must point to a value of the type corresponding to the
395 * requested option kind (see GhosttyRenderStateOption).
396 *
397 * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
398 * @param option The option to set
399 * @param[in] value Pointer to the value to set (NULL returns
400 * GHOSTTY_INVALID_VALUE)
401 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or
402 * `value` is NULL
403 *
404 * @ingroup render
405 */
406GHOSTTY_API GhosttyResult ghostty_render_state_set(GhosttyRenderState state,
407 GhosttyRenderStateOption option,
408 const void* value);
409
410/**
411 * Get the current color information from a render state.
412 *
413 * This writes as many fields as fit in the caller-provided sized struct.
414 * `out_colors->size` must be set by the caller (typically via
415 * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors)).
416 *
417 * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE)
418 * @param[out] out_colors Sized output struct to receive render-state colors
419 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or
420 * `out_colors` is NULL, or if `out_colors->size` is smaller than
421 * `sizeof(size_t)`
422 *
423 * @ingroup render
424 */
425GHOSTTY_API GhosttyResult ghostty_render_state_colors_get(GhosttyRenderState state,
426 GhosttyRenderStateColors* out_colors);
427
428/**
429 * Create a new row iterator instance.
430 *
431 * All fields except the allocator are left undefined until populated
432 * via ghostty_render_state_get() with
433 * GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR.
434 *
435 * @param allocator Pointer to allocator, or NULL to use the default allocator
436 * @param[out] out_iterator On success, receives the created iterator handle
437 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation
438 * failure
439 *
440 * @ingroup render
441 */
442GHOSTTY_API GhosttyResult ghostty_render_state_row_iterator_new(
443 const GhosttyAllocator* allocator,
444 GhosttyRenderStateRowIterator* out_iterator);
445
446/**
447 * Free a render-state row iterator.
448 *
449 * @param iterator The iterator handle to free (may be NULL)
450 *
451 * @ingroup render
452 */
453GHOSTTY_API void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIterator iterator);
454
455/**
456 * Move a render-state row iterator to the next row.
457 *
458 * Returns true if the iterator moved successfully and row data is
459 * available to read at the new position.
460 *
461 * @param iterator The iterator handle to advance (may be NULL)
462 * @return true if advanced to the next row, false if `iterator` is
463 * NULL or if the iterator has reached the end
464 *
465 * @ingroup render
466 */
467GHOSTTY_API bool ghostty_render_state_row_iterator_next(GhosttyRenderStateRowIterator iterator);
468
469/**
470 * Get a value from the current row in a render-state row iterator.
471 *
472 * The `out` pointer must point to a value of the type corresponding to the
473 * requested data kind (see GhosttyRenderStateRowData).
474 * Call ghostty_render_state_row_iterator_next() at least once before
475 * calling this function.
476 *
477 * @param iterator The iterator handle to query (NULL returns GHOSTTY_INVALID_VALUE)
478 * @param data The data kind to query
479 * @param[out] out Pointer to receive the queried value
480 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if
481 * `iterator` is NULL or the iterator is not positioned on a row
482 *
483 * @ingroup render
484 */
485GHOSTTY_API GhosttyResult ghostty_render_state_row_get(
486 GhosttyRenderStateRowIterator iterator,
487 GhosttyRenderStateRowData data,
488 void* out);
489
490/**
491 * Get multiple data fields from the current row in a single call.
492 *
493 * Each element in the keys array specifies a data kind, and the
494 * corresponding element in the values array receives the result.
495 *
496 * Processing stops at the first error; on success out_written
497 * is set to count, on error it is set to the index of the
498 * failing key (i.e. the number of values successfully written).
499 *
500 * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE)
501 * @param count Number of key/value pairs
502 * @param keys Array of data kinds to query
503 * @param values Array of output pointers (types must match each key's
504 * documented output type)
505 * @param[out] out_written On return, receives the number of values
506 * successfully written (may be NULL)
507 * @return GHOSTTY_SUCCESS if all queries succeed
508 *
509 * @ingroup render
510 */
511GHOSTTY_API GhosttyResult ghostty_render_state_row_get_multi(
512 GhosttyRenderStateRowIterator iterator,
513 size_t count,
514 const GhosttyRenderStateRowData* keys,
515 void** values,
516 size_t* out_written);
517
518/**
519 * Set an option on the current row in a render-state row iterator.
520 *
521 * The `value` pointer must point to a value of the type corresponding to the
522 * requested option kind (see GhosttyRenderStateRowOption).
523 * Call ghostty_render_state_row_iterator_next() at least once before
524 * calling this function.
525 *
526 * @param iterator The iterator handle to update (NULL returns GHOSTTY_INVALID_VALUE)
527 * @param option The option to set
528 * @param[in] value Pointer to the value to set (NULL returns
529 * GHOSTTY_INVALID_VALUE)
530 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if
531 * `iterator` is NULL or the iterator is not positioned on a row
532 *
533 * @ingroup render
534 */
535GHOSTTY_API GhosttyResult ghostty_render_state_row_set(
536 GhosttyRenderStateRowIterator iterator,
537 GhosttyRenderStateRowOption option,
538 const void* value);
539
540/**
541 * Create a new row cells instance.
542 *
543 * All fields except the allocator are left undefined until populated
544 * via ghostty_render_state_row_get() with
545 * GHOSTTY_RENDER_STATE_ROW_DATA_CELLS.
546 *
547 * You can reuse this value repeatedly with ghostty_render_state_row_get() to
548 * avoid allocating a new cells container for every row.
549 *
550 * @param allocator Pointer to allocator, or NULL to use the default allocator
551 * @param[out] out_cells On success, receives the created row cells handle
552 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation
553 * failure
554 *
555 * @ingroup render
556 */
557GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_new(
558 const GhosttyAllocator* allocator,
559 GhosttyRenderStateRowCells* out_cells);
560
561/**
562 * Queryable data kinds for ghostty_render_state_row_cells_get().
563 *
564 * @ingroup render
565 */
566typedef enum GHOSTTY_ENUM_TYPED {
567 /** Invalid / sentinel value. */
568 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_INVALID = 0,
569
570 /** The raw cell value (GhosttyCell). */
571 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW = 1,
572
573 /** The style for the current cell (GhosttyStyle). */
574 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE = 2,
575
576 /** The total number of grapheme codepoints including the base codepoint
577 * (uint32_t). Returns 0 if the cell has no text. */
578 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN = 3,
579
580 /** Write grapheme codepoints into a caller-provided buffer (uint32_t*).
581 * The buffer must be at least graphemes_len elements. The base codepoint
582 * is written first, followed by any extra codepoints. */
583 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF = 4,
584
585 /** The resolved background color of the cell (GhosttyColorRgb).
586 * Flattens the three possible sources: content-tag bg_color_rgb,
587 * content-tag bg_color_palette (looked up in the palette), or the
588 * style's bg_color. Returns GHOSTTY_INVALID_VALUE if the cell has
589 * no background color, in which case the caller should use whatever
590 * default background color it wants (e.g. the terminal background). */
591 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR = 5,
592
593 /** The resolved foreground color of the cell (GhosttyColorRgb).
594 * Resolves palette indices through the palette. Bold color handling
595 * is not applied; the caller should handle bold styling separately.
596 * Returns GHOSTTY_INVALID_VALUE if the cell has no explicit foreground
597 * color, in which case the caller should use whatever default foreground
598 * color it wants (e.g. the terminal foreground). */
599 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6,
600
601 /** Whether the cell is contained within the current selection (bool).
602 * This returns true when the cell's column is within the current row's
603 * row-local selection range, and false otherwise. Rendering policy for
604 * selected cells (colors, inversion, etc.) is left to the caller.
605 *
606 * Renderers that can draw cells in spans may be more efficient querying
607 * GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION once per row and applying that
608 * range directly, avoiding one C API call per cell for selection state. */
609 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED = 7,
610
611 /** Whether the cell has any explicit styling (bool).
612 * This is equivalent to querying the raw cell's
613 * GHOSTTY_CELL_DATA_HAS_STYLING value, but avoids materializing the raw
614 * GhosttyCell for renderers that only need to know whether fetching the
615 * full style is necessary. */
616 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING = 8,
617
618 /**
619 * Encode the current cell's full grapheme cluster as UTF-8 into a
620 * caller-provided buffer (GhosttyBuffer).
621 *
622 * The base codepoint is encoded first, followed by any extra grapheme
623 * codepoints. Returns GHOSTTY_SUCCESS with len=0 when the cell has no text.
624 *
625 * If ptr is NULL or cap is too small for a non-empty cell, returns
626 * GHOSTTY_OUT_OF_SPACE without writing any bytes and sets len to the required
627 * buffer size in bytes.
628 */
629 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8 = 9,
630 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
631} GhosttyRenderStateRowCellsData;
632
633/**
634 * Move a render-state row cells iterator to the next cell.
635 *
636 * Returns true if the iterator moved successfully and cell data is
637 * available to read at the new position.
638 *
639 * @param cells The row cells handle to advance (may be NULL)
640 * @return true if advanced to the next cell, false if `cells` is
641 * NULL or if the iterator has reached the end
642 *
643 * @ingroup render
644 */
645GHOSTTY_API bool ghostty_render_state_row_cells_next(GhosttyRenderStateRowCells cells);
646
647/**
648 * Move a render-state row cells iterator to a specific column.
649 *
650 * Positions the iterator at the given x (column) index so that
651 * subsequent reads return data for that cell.
652 *
653 * @param cells The row cells handle to reposition (NULL returns
654 * GHOSTTY_INVALID_VALUE)
655 * @param x The zero-based column index to select
656 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `cells`
657 * is NULL or `x` is out of range
658 *
659 * @ingroup render
660 */
661GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_select(
662 GhosttyRenderStateRowCells cells, uint16_t x);
663
664/**
665 * Get a value from the current cell in a render-state row cells iterator.
666 *
667 * The `out` pointer must point to a value of the type corresponding to the
668 * requested data kind (see GhosttyRenderStateRowCellsData).
669 * Call ghostty_render_state_row_cells_next() or
670 * ghostty_render_state_row_cells_select() at least once before
671 * calling this function.
672 *
673 * @param cells The row cells handle to query (NULL returns GHOSTTY_INVALID_VALUE)
674 * @param data The data kind to query
675 * @param[out] out Pointer to receive the queried value
676 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if
677 * `cells` is NULL or the iterator is not positioned on a cell
678 *
679 * @ingroup render
680 */
681GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_get(
682 GhosttyRenderStateRowCells cells,
683 GhosttyRenderStateRowCellsData data,
684 void* out);
685
686/**
687 * Get multiple data fields from the current cell in a single call.
688 *
689 * Each element in the keys array specifies a data kind, and the
690 * corresponding element in the values array receives the result.
691 *
692 * Processing stops at the first error; on success out_written
693 * is set to count, on error it is set to the index of the
694 * failing key (i.e. the number of values successfully written).
695 *
696 * @param cells The row cells handle (NULL returns GHOSTTY_INVALID_VALUE)
697 * @param count Number of key/value pairs
698 * @param keys Array of data kinds to query
699 * @param values Array of output pointers (types must match each key's
700 * documented output type)
701 * @param[out] out_written On return, receives the number of values
702 * successfully written (may be NULL)
703 * @return GHOSTTY_SUCCESS if all queries succeed
704 *
705 * @ingroup render
706 */
707GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_get_multi(
708 GhosttyRenderStateRowCells cells,
709 size_t count,
710 const GhosttyRenderStateRowCellsData* keys,
711 void** values,
712 size_t* out_written);
713
714/**
715 * Free a row cells instance.
716 *
717 * @param cells The row cells handle to free (may be NULL)
718 *
719 * @ingroup render
720 */
721GHOSTTY_API void ghostty_render_state_row_cells_free(GhosttyRenderStateRowCells cells);
722
723/** @} */
724
725#ifdef __cplusplus
726}
727#endif
728
729#endif /* GHOSTTY_VT_RENDER_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/screen.h created+400
...@@ -0,0 +1,400 @@
1/**
2 * @file screen.h
3 *
4 * Terminal screen cell and row types.
5 */
6
7#ifndef GHOSTTY_VT_SCREEN_H
8#define GHOSTTY_VT_SCREEN_H
9
10#include <stdbool.h>
11#include <stdint.h>
12#include <ghostty/vt/color.h>
13#include <ghostty/vt/types.h>
14
15#ifdef __cplusplus
16extern "C" {
17#endif
18
19/** @defgroup screen Screen
20 *
21 * Terminal screen cell and row types.
22 *
23 * These types represent the contents of a terminal screen. A GhosttyCell
24 * is a single grid cell and a GhosttyRow is a single row. Both are opaque
25 * values whose fields are accessed via ghostty_cell_get() and
26 * ghostty_row_get() respectively.
27 *
28 * @{
29 */
30
31/**
32 * Opaque cell value.
33 *
34 * Represents a single terminal cell. The internal layout is opaque and
35 * must be queried via ghostty_cell_get(). Obtain cell values from
36 * terminal query APIs.
37 *
38 * @ingroup screen
39 */
40typedef uint64_t GhosttyCell;
41
42/**
43 * Opaque row value.
44 *
45 * Represents a single terminal row. The internal layout is opaque and
46 * must be queried via ghostty_row_get(). Obtain row values from
47 * terminal query APIs.
48 *
49 * @ingroup screen
50 */
51typedef uint64_t GhosttyRow;
52
53/**
54 * Cell content tag.
55 *
56 * Describes what kind of content a cell holds.
57 *
58 * @ingroup screen
59 */
60typedef enum GHOSTTY_ENUM_TYPED {
61 /** A single codepoint (may be zero for empty). */
62 GHOSTTY_CELL_CONTENT_CODEPOINT = 0,
63
64 /** A codepoint that is part of a multi-codepoint grapheme cluster. */
65 GHOSTTY_CELL_CONTENT_CODEPOINT_GRAPHEME = 1,
66
67 /** No text; background color from palette. */
68 GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE = 2,
69
70 /** No text; background color as RGB. */
71 GHOSTTY_CELL_CONTENT_BG_COLOR_RGB = 3,
72 GHOSTTY_CELL_CONTENT_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
73} GhosttyCellContentTag;
74
75/**
76 * Cell wide property.
77 *
78 * Describes the width behavior of a cell.
79 *
80 * @ingroup screen
81 */
82typedef enum GHOSTTY_ENUM_TYPED {
83 /** Not a wide character, cell width 1. */
84 GHOSTTY_CELL_WIDE_NARROW = 0,
85
86 /** Wide character, cell width 2. */
87 GHOSTTY_CELL_WIDE_WIDE = 1,
88
89 /** Spacer after wide character. Do not render. */
90 GHOSTTY_CELL_WIDE_SPACER_TAIL = 2,
91
92 /** Spacer at end of soft-wrapped line for a wide character. */
93 GHOSTTY_CELL_WIDE_SPACER_HEAD = 3,
94 GHOSTTY_CELL_WIDE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
95} GhosttyCellWide;
96
97/**
98 * Semantic content type of a cell.
99 *
100 * Set by semantic prompt sequences (OSC 133) to distinguish between
101 * command output, user input, and shell prompt text.
102 *
103 * @ingroup screen
104 */
105typedef enum GHOSTTY_ENUM_TYPED {
106 /** Regular output content, such as command output. */
107 GHOSTTY_CELL_SEMANTIC_OUTPUT = 0,
108
109 /** Content that is part of user input. */
110 GHOSTTY_CELL_SEMANTIC_INPUT = 1,
111
112 /** Content that is part of a shell prompt. */
113 GHOSTTY_CELL_SEMANTIC_PROMPT = 2,
114 GHOSTTY_CELL_SEMANTIC_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
115} GhosttyCellSemanticContent;
116
117/**
118 * Cell data types.
119 *
120 * These values specify what type of data to extract from a cell
121 * using `ghostty_cell_get`.
122 *
123 * @ingroup screen
124 */
125typedef enum GHOSTTY_ENUM_TYPED {
126 /** Invalid data type. Never results in any data extraction. */
127 GHOSTTY_CELL_DATA_INVALID = 0,
128
129 /**
130 * The codepoint of the cell (0 if empty or bg-color-only).
131 *
132 * Output type: uint32_t *
133 */
134 GHOSTTY_CELL_DATA_CODEPOINT = 1,
135
136 /**
137 * The content tag describing what kind of content is in the cell.
138 *
139 * Output type: GhosttyCellContentTag *
140 */
141 GHOSTTY_CELL_DATA_CONTENT_TAG = 2,
142
143 /**
144 * The wide property of the cell.
145 *
146 * Output type: GhosttyCellWide *
147 */
148 GHOSTTY_CELL_DATA_WIDE = 3,
149
150 /**
151 * Whether the cell has text to render.
152 *
153 * Output type: bool *
154 */
155 GHOSTTY_CELL_DATA_HAS_TEXT = 4,
156
157 /**
158 * Whether the cell has non-default styling.
159 *
160 * Output type: bool *
161 */
162 GHOSTTY_CELL_DATA_HAS_STYLING = 5,
163
164 /**
165 * The style ID for the cell (for use with style lookups).
166 *
167 * Output type: uint16_t *
168 */
169 GHOSTTY_CELL_DATA_STYLE_ID = 6,
170
171 /**
172 * Whether the cell has a hyperlink.
173 *
174 * Output type: bool *
175 */
176 GHOSTTY_CELL_DATA_HAS_HYPERLINK = 7,
177
178 /**
179 * Whether the cell is protected.
180 *
181 * Output type: bool *
182 */
183 GHOSTTY_CELL_DATA_PROTECTED = 8,
184
185 /**
186 * The semantic content type of the cell (from OSC 133).
187 *
188 * Output type: GhosttyCellSemanticContent *
189 */
190 GHOSTTY_CELL_DATA_SEMANTIC_CONTENT = 9,
191
192 /**
193 * The palette index for the cell's background color.
194 * Only valid when content_tag is GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE.
195 *
196 * Output type: GhosttyColorPaletteIndex *
197 */
198 GHOSTTY_CELL_DATA_COLOR_PALETTE = 10,
199
200 /**
201 * The RGB value for the cell's background color.
202 * Only valid when content_tag is GHOSTTY_CELL_CONTENT_BG_COLOR_RGB.
203 *
204 * Output type: GhosttyColorRgb *
205 */
206 GHOSTTY_CELL_DATA_COLOR_RGB = 11,
207 GHOSTTY_CELL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
208} GhosttyCellData;
209
210/**
211 * Row semantic prompt state.
212 *
213 * Indicates whether any cells in a row are part of a shell prompt,
214 * as reported by OSC 133 sequences.
215 *
216 * @ingroup screen
217 */
218typedef enum GHOSTTY_ENUM_TYPED {
219 /** No prompt cells in this row. */
220 GHOSTTY_ROW_SEMANTIC_NONE = 0,
221
222 /** Prompt cells exist and this is a primary prompt line. */
223 GHOSTTY_ROW_SEMANTIC_PROMPT = 1,
224
225 /** Prompt cells exist and this is a continuation line. */
226 GHOSTTY_ROW_SEMANTIC_PROMPT_CONTINUATION = 2,
227 GHOSTTY_ROW_SEMANTIC_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
228} GhosttyRowSemanticPrompt;
229
230/**
231 * Row data types.
232 *
233 * These values specify what type of data to extract from a row
234 * using `ghostty_row_get`.
235 *
236 * @ingroup screen
237 */
238typedef enum GHOSTTY_ENUM_TYPED {
239 /** Invalid data type. Never results in any data extraction. */
240 GHOSTTY_ROW_DATA_INVALID = 0,
241
242 /**
243 * Whether this row is soft-wrapped.
244 *
245 * Output type: bool *
246 */
247 GHOSTTY_ROW_DATA_WRAP = 1,
248
249 /**
250 * Whether this row is a continuation of a soft-wrapped row.
251 *
252 * Output type: bool *
253 */
254 GHOSTTY_ROW_DATA_WRAP_CONTINUATION = 2,
255
256 /**
257 * Whether any cells in this row have grapheme clusters.
258 *
259 * Output type: bool *
260 */
261 GHOSTTY_ROW_DATA_GRAPHEME = 3,
262
263 /**
264 * Whether any cells in this row have styling (may have false positives).
265 *
266 * Output type: bool *
267 */
268 GHOSTTY_ROW_DATA_STYLED = 4,
269
270 /**
271 * Whether any cells in this row have hyperlinks (may have false positives).
272 *
273 * Output type: bool *
274 */
275 GHOSTTY_ROW_DATA_HYPERLINK = 5,
276
277 /**
278 * The semantic prompt state of this row.
279 *
280 * Output type: GhosttyRowSemanticPrompt *
281 */
282 GHOSTTY_ROW_DATA_SEMANTIC_PROMPT = 6,
283
284 /**
285 * Whether this row contains a Kitty virtual placeholder.
286 *
287 * Output type: bool *
288 */
289 GHOSTTY_ROW_DATA_KITTY_VIRTUAL_PLACEHOLDER = 7,
290
291 /**
292 * Whether this row is dirty and requires a redraw.
293 *
294 * Output type: bool *
295 */
296 GHOSTTY_ROW_DATA_DIRTY = 8,
297 GHOSTTY_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
298} GhosttyRowData;
299
300/**
301 * Get data from a cell.
302 *
303 * Extracts typed data from the given cell based on the specified
304 * data type. The output pointer must be of the appropriate type for the
305 * requested data kind. Valid data types and output types are documented
306 * in the `GhosttyCellData` enum.
307 *
308 * @param cell The cell value
309 * @param data The type of data to extract
310 * @param out Pointer to store the extracted data (type depends on data parameter)
311 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the
312 * data type is invalid
313 *
314 * @ingroup screen
315 */
316GHOSTTY_API GhosttyResult ghostty_cell_get(GhosttyCell cell,
317 GhosttyCellData data,
318 void *out);
319
320/**
321 * Get multiple data fields from a cell in a single call.
322 *
323 * Each element in the keys array specifies a data kind, and the
324 * corresponding element in the values array receives the result.
325 *
326 * Processing stops at the first error; on success out_written
327 * is set to count, on error it is set to the index of the
328 * failing key (i.e. the number of values successfully written).
329 *
330 * @param cell The cell value
331 * @param count Number of key/value pairs
332 * @param keys Array of data kinds to query
333 * @param values Array of output pointers (types must match each key's
334 * documented output type)
335 * @param[out] out_written On return, receives the number of values
336 * successfully written (may be NULL)
337 * @return GHOSTTY_SUCCESS if all queries succeed
338 *
339 * @ingroup screen
340 */
341GHOSTTY_API GhosttyResult ghostty_cell_get_multi(GhosttyCell cell,
342 size_t count,
343 const GhosttyCellData* keys,
344 void** values,
345 size_t* out_written);
346
347/**
348 * Get data from a row.
349 *
350 * Extracts typed data from the given row based on the specified
351 * data type. The output pointer must be of the appropriate type for the
352 * requested data kind. Valid data types and output types are documented
353 * in the `GhosttyRowData` enum.
354 *
355 * @param row The row value
356 * @param data The type of data to extract
357 * @param out Pointer to store the extracted data (type depends on data parameter)
358 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the
359 * data type is invalid
360 *
361 * @ingroup screen
362 */
363GHOSTTY_API GhosttyResult ghostty_row_get(GhosttyRow row,
364 GhosttyRowData data,
365 void *out);
366
367/**
368 * Get multiple data fields from a row in a single call.
369 *
370 * Each element in the keys array specifies a data kind, and the
371 * corresponding element in the values array receives the result.
372 *
373 * Processing stops at the first error; on success out_written
374 * is set to count, on error it is set to the index of the
375 * failing key (i.e. the number of values successfully written).
376 *
377 * @param row The row value
378 * @param count Number of key/value pairs
379 * @param keys Array of data kinds to query
380 * @param values Array of output pointers (types must match each key's
381 * documented output type)
382 * @param[out] out_written On return, receives the number of values
383 * successfully written (may be NULL)
384 * @return GHOSTTY_SUCCESS if all queries succeed
385 *
386 * @ingroup screen
387 */
388GHOSTTY_API GhosttyResult ghostty_row_get_multi(GhosttyRow row,
389 size_t count,
390 const GhosttyRowData* keys,
391 void** values,
392 size_t* out_written);
393
394/** @} */
395
396#ifdef __cplusplus
397}
398#endif
399
400#endif /* GHOSTTY_VT_SCREEN_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/selection.h created+1061
...@@ -0,0 +1,1061 @@
1/**
2 * @file selection.h
3 *
4 * Selection range type for specifying a region of terminal content.
5 */
6
7#ifndef GHOSTTY_VT_SELECTION_H
8#define GHOSTTY_VT_SELECTION_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/allocator.h>
14#include <ghostty/vt/grid_ref.h>
15#include <ghostty/vt/point.h>
16#include <ghostty/vt/types.h>
17
18#ifdef __cplusplus
19extern "C" {
20#endif
21
22/** @defgroup selection Selection
23 *
24 * A snapshot selection range defined by two grid references that identifies
25 * a contiguous or rectangular region of terminal content.
26 *
27 * The start and end values are GhosttyGridRef values. They are therefore
28 * untracked grid references and inherit the same lifetime rules: they are
29 * only safe to use until the next mutating operation on the terminal that
30 * produced them, including freeing the terminal. To keep a selection valid
31 * across terminal mutations, callers must maintain tracked grid references
32 * for the endpoints and reconstruct a GhosttySelection from fresh snapshots
33 * when needed.
34 *
35 * Selection gestures provide a reusable state machine for turning UI pointer
36 * interactions into selection snapshots. A caller creates one
37 * GhosttySelectionGesture per active gesture stream, reuses typed
38 * GhosttySelectionGestureEvent objects for synthetic press, drag, release,
39 * autoscroll tick, and deep-press events, and applies each event with
40 * ghostty_selection_gesture_event(). The returned GhosttySelection is a
41 * snapshot; the embedder decides whether to render it, format/copy it, or
42 * install it as the terminal's active selection.
43 *
44 * ## Examples
45 *
46 * @snippet c-vt-selection/src/main.c selection-main
47 * @snippet c-vt-selection-gesture/src/main.c selection-gesture-main
48 *
49 * @{
50 */
51
52/**
53 * Opaque handle to state for interpreting terminal selection gestures.
54 *
55 * The gesture owns only the state required to interpret pointer events. Calls
56 * that use a gesture are not concurrency-safe and must be serialized with
57 * terminal mutations.
58 *
59 * @ingroup selection
60 */
61typedef struct GhosttySelectionGestureImpl* GhosttySelectionGesture;
62
63/**
64 * Opaque handle to reusable input data for selection gesture operations.
65 *
66 * Event options are set with ghostty_selection_gesture_event_set(). Individual
67 * gesture operations document which options are required or optional.
68 *
69 * @ingroup selection
70 */
71typedef struct GhosttySelectionGestureEventImpl* GhosttySelectionGestureEvent;
72
73/**
74 * A snapshot selection range defined by two grid references.
75 *
76 * Both endpoints are inclusive. The endpoints preserve selection direction
77 * and may be reversed; callers must not assume that start is the top-left
78 * endpoint or that end is the bottom-right endpoint.
79 *
80 * When rectangle is false, the endpoints describe a linear selection. When
81 * rectangle is true, the same endpoints are interpreted as opposite corners
82 * of a rectangular/block selection.
83 *
84 * The start and end values are untracked GhosttyGridRef snapshots and are
85 * only valid until the next mutating operation on the terminal that produced
86 * them unless the selection is reconstructed from tracked references.
87 *
88 * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
89 *
90 * @ingroup selection
91 */
92typedef struct {
93 /** Size of this struct in bytes. Must be set to sizeof(GhosttySelection). */
94 size_t size;
95
96 /**
97 * Start of the selection range (inclusive).
98 *
99 * This may be after end in terminal order. It is an untracked
100 * GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules.
101 */
102 GhosttyGridRef start;
103
104 /**
105 * End of the selection range (inclusive).
106 *
107 * This may be before start in terminal order. It is an untracked
108 * GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules.
109 */
110 GhosttyGridRef end;
111
112 /**
113 * Whether the endpoints are interpreted as a rectangular/block selection
114 * rather than a linear selection.
115 */
116 bool rectangle;
117} GhosttySelection;
118
119/**
120 * Options for deriving a word selection from a terminal grid reference.
121 *
122 * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
123 * If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's
124 * default word-boundary codepoints are used. If boundary_codepoints_len is
125 * non-zero, boundary_codepoints must not be NULL.
126 *
127 * @ingroup selection
128 */
129typedef struct {
130 /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordOptions). */
131 size_t size;
132
133 /** Grid reference under which to derive the word selection. */
134 GhosttyGridRef ref;
135
136 /** Optional word-boundary codepoints as uint32_t scalar values. */
137 const uint32_t* boundary_codepoints;
138
139 /** Number of entries in boundary_codepoints. */
140 size_t boundary_codepoints_len;
141} GhosttyTerminalSelectWordOptions;
142
143/**
144 * Options for deriving the nearest word selection between two grid references.
145 *
146 * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
147 * If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's
148 * default word-boundary codepoints are used. If boundary_codepoints_len is
149 * non-zero, boundary_codepoints must not be NULL.
150 *
151 * @ingroup selection
152 */
153typedef struct {
154 /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordBetweenOptions). */
155 size_t size;
156
157 /** Starting grid reference for the inclusive search range. */
158 GhosttyGridRef start;
159
160 /** Ending grid reference for the inclusive search range. */
161 GhosttyGridRef end;
162
163 /** Optional word-boundary codepoints as uint32_t scalar values. */
164 const uint32_t* boundary_codepoints;
165
166 /** Number of entries in boundary_codepoints. */
167 size_t boundary_codepoints_len;
168} GhosttyTerminalSelectWordBetweenOptions;
169
170/**
171 * Options for deriving a line selection from a terminal grid reference.
172 *
173 * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
174 * If whitespace is NULL and whitespace_len is 0, Ghostty's default line-trim
175 * whitespace codepoints are used. If whitespace_len is non-zero, whitespace
176 * must not be NULL.
177 *
178 * @ingroup selection
179 */
180typedef struct {
181 /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectLineOptions). */
182 size_t size;
183
184 /** Grid reference under which to derive the line selection. */
185 GhosttyGridRef ref;
186
187 /** Optional codepoints to trim from the start and end of the line. */
188 const uint32_t* whitespace;
189
190 /** Number of entries in whitespace. */
191 size_t whitespace_len;
192
193 /** Whether semantic prompt state changes should bound the line selection. */
194 bool semantic_prompt_boundary;
195} GhosttyTerminalSelectLineOptions;
196
197/**
198 * Options for one-shot formatting of a terminal selection.
199 *
200 * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
201 *
202 * If selection is NULL, the terminal's current active selection is used.
203 * If selection is non-NULL, that caller-provided snapshot selection is used.
204 *
205 * The selection is formatted from the terminal's active screen using the same
206 * formatting semantics as GhosttyFormatter. For copy/clipboard behavior
207 * matching Ghostty's Screen.selectionString(), use plain output with unwrap
208 * and trim both set to true.
209 *
210 * @ingroup selection
211 */
212typedef struct {
213 /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectionFormatOptions). */
214 size_t size;
215
216 /** Output format to emit. */
217 GhosttyFormatterFormat emit;
218
219 /** Whether to unwrap soft-wrapped lines. */
220 bool unwrap;
221
222 /** Whether to trim trailing whitespace on non-blank lines. */
223 bool trim;
224
225 /**
226 * Optional selection to format.
227 *
228 * If NULL, the terminal's current active selection is used. If the terminal
229 * has no active selection, formatting returns GHOSTTY_NO_VALUE.
230 *
231 * If non-NULL, the pointed-to selection must be a valid snapshot selection
232 * for this terminal and must obey GhosttySelection lifetime rules.
233 */
234 const GhosttySelection *selection;
235} GhosttyTerminalSelectionFormatOptions;
236
237/**
238 * Ordering of a selection's endpoints in terminal coordinates.
239 *
240 * Mirrored orders are only produced by rectangular selections whose start
241 * and end endpoints are on opposite diagonal corners that are not simple
242 * top-left-to-bottom-right or bottom-right-to-top-left orderings.
243 *
244 * @ingroup selection
245 */
246typedef enum GHOSTTY_ENUM_TYPED {
247 /** Start is before end in top-left to bottom-right order. */
248 GHOSTTY_SELECTION_ORDER_FORWARD = 0,
249
250 /** End is before start in top-left to bottom-right order. */
251 GHOSTTY_SELECTION_ORDER_REVERSE = 1,
252
253 /** Rectangular selection from top-right to bottom-left. */
254 GHOSTTY_SELECTION_ORDER_MIRRORED_FORWARD = 2,
255
256 /** Rectangular selection from bottom-left to top-right. */
257 GHOSTTY_SELECTION_ORDER_MIRRORED_REVERSE = 3,
258
259 GHOSTTY_SELECTION_ORDER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
260} GhosttySelectionOrder;
261
262/**
263 * Operation used to adjust a selection endpoint.
264 *
265 * Adjustment mutates the selection's logical end endpoint, not whichever
266 * endpoint is visually bottom/right. This preserves keyboard and drag
267 * behavior for both forward and reversed selections.
268 *
269 * @ingroup selection
270 */
271typedef enum GHOSTTY_ENUM_TYPED {
272 /** Move left to the previous non-empty cell, wrapping upward. */
273 GHOSTTY_SELECTION_ADJUST_LEFT = 0,
274
275 /** Move right to the next non-empty cell, wrapping downward. */
276 GHOSTTY_SELECTION_ADJUST_RIGHT = 1,
277
278 /**
279 * Move up one row at the current column, or to the beginning of the
280 * line if already at the top.
281 */
282 GHOSTTY_SELECTION_ADJUST_UP = 2,
283
284 /**
285 * Move down to the next non-blank row at the current column, or to the
286 * end of the line if none exists.
287 */
288 GHOSTTY_SELECTION_ADJUST_DOWN = 3,
289
290 /** Move to the top-left cell of the screen. */
291 GHOSTTY_SELECTION_ADJUST_HOME = 4,
292
293 /** Move to the right edge of the last non-blank row on the screen. */
294 GHOSTTY_SELECTION_ADJUST_END = 5,
295
296 /**
297 * Move up by one terminal page height, or to home if that would move
298 * past the top.
299 */
300 GHOSTTY_SELECTION_ADJUST_PAGE_UP = 6,
301
302 /**
303 * Move down by one terminal page height, or to end if that would move
304 * past the bottom.
305 */
306 GHOSTTY_SELECTION_ADJUST_PAGE_DOWN = 7,
307
308 /** Move to the left edge of the current line. */
309 GHOSTTY_SELECTION_ADJUST_BEGINNING_OF_LINE = 8,
310
311 /** Move to the right edge of the current line. */
312 GHOSTTY_SELECTION_ADJUST_END_OF_LINE = 9,
313
314 GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
315} GhosttySelectionAdjust;
316
317/**
318 * Selection behavior chosen for a gesture's click sequence.
319 *
320 * @ingroup selection
321 */
322typedef enum GHOSTTY_ENUM_TYPED {
323 /** Cell-granular drag selection. */
324 GHOSTTY_SELECTION_GESTURE_BEHAVIOR_CELL = 0,
325
326 /** Word selection on press and word-granular drag selection. */
327 GHOSTTY_SELECTION_GESTURE_BEHAVIOR_WORD = 1,
328
329 /** Line selection on press and line-granular drag selection. */
330 GHOSTTY_SELECTION_GESTURE_BEHAVIOR_LINE = 2,
331
332 /** Semantic command output selection on press and drag. */
333 GHOSTTY_SELECTION_GESTURE_BEHAVIOR_OUTPUT = 3,
334
335 GHOSTTY_SELECTION_GESTURE_BEHAVIOR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
336} GhosttySelectionGestureBehavior;
337
338/**
339 * Selection behaviors for single-, double-, and triple-click gestures.
340 *
341 * @ingroup selection
342 */
343typedef struct {
344 /** Behavior for single-click selection gestures. */
345 GhosttySelectionGestureBehavior single_click;
346
347 /** Behavior for double-click selection gestures. */
348 GhosttySelectionGestureBehavior double_click;
349
350 /** Behavior for triple-click selection gestures. */
351 GhosttySelectionGestureBehavior triple_click;
352} GhosttySelectionGestureBehaviors;
353
354/**
355 * Display geometry used to interpret selection gesture drag events.
356 *
357 * @ingroup selection
358 */
359typedef struct {
360 /** Number of columns in the rendered terminal grid. Must be non-zero. */
361 uint32_t columns;
362
363 /** Width of one terminal cell in surface pixels. Must be non-zero. */
364 uint32_t cell_width;
365
366 /** Left padding before the terminal grid begins in surface pixels. */
367 uint32_t padding_left;
368
369 /** Height of the rendered terminal surface in surface pixels. Must be non-zero. */
370 uint32_t screen_height;
371} GhosttySelectionGestureGeometry;
372
373/**
374 * Current autoscroll direction for an active selection drag gesture.
375 *
376 * @ingroup selection
377 */
378typedef enum GHOSTTY_ENUM_TYPED {
379 /** No selection autoscroll is requested. */
380 GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_NONE = 0,
381
382 /** Selection dragging should autoscroll the viewport upward. */
383 GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_UP = 1,
384
385 /** Selection dragging should autoscroll the viewport downward. */
386 GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_DOWN = 2,
387
388 GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
389} GhosttySelectionGestureAutoscroll;
390
391/**
392 * Data fields readable from a selection gesture with
393 * ghostty_selection_gesture_get().
394 *
395 * @ingroup selection
396 */
397typedef enum GHOSTTY_ENUM_TYPED {
398 /** Current click count: uint8_t*. 0 means inactive. */
399 GHOSTTY_SELECTION_GESTURE_DATA_CLICK_COUNT = 0,
400
401 /** Whether the current/last left-click gesture has dragged: bool*. */
402 GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED = 1,
403
404 /** Current autoscroll request: GhosttySelectionGestureAutoscroll*. */
405 GHOSTTY_SELECTION_GESTURE_DATA_AUTOSCROLL = 2,
406
407 /** Current gesture behavior: GhosttySelectionGestureBehavior*. */
408 GHOSTTY_SELECTION_GESTURE_DATA_BEHAVIOR = 3,
409
410 /**
411 * Current left-click anchor: GhosttyGridRef*.
412 *
413 * Returns GHOSTTY_NO_VALUE if there is no valid active anchor. On success,
414 * writes an untracked GhosttyGridRef snapshot with normal GhosttyGridRef
415 * lifetime rules.
416 */
417 GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR = 4,
418
419 GHOSTTY_SELECTION_GESTURE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
420} GhosttySelectionGestureData;
421
422/**
423 * Selection gesture event type.
424 *
425 * The event type is fixed when the event is created. Each event type documents
426 * which options are valid and which options are required by gesture operations.
427 *
428 * @ingroup selection
429 */
430typedef enum GHOSTTY_ENUM_TYPED {
431 /** Press event for ghostty_selection_gesture_event(). */
432 GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS = 0,
433
434 /** Release event for ghostty_selection_gesture_event(). */
435 GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE = 1,
436
437 /** Drag event for ghostty_selection_gesture_event(). */
438 GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG = 2,
439
440 /** Autoscroll tick event for ghostty_selection_gesture_event(). */
441 GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK = 3,
442
443 /** Deep press event for ghostty_selection_gesture_event(). */
444 GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS = 4,
445
446 GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
447} GhosttySelectionGestureEventType;
448
449/**
450 * Options stored on a reusable selection gesture event.
451 *
452 * Passing NULL as the value to ghostty_selection_gesture_event_set() clears the
453 * corresponding option.
454 *
455 * @ingroup selection
456 */
457typedef enum GHOSTTY_ENUM_TYPED {
458 /**
459 * Grid reference under the pointer: GhosttyGridRef*.
460 *
461 * Required for PRESS and DRAG events. Optional for RELEASE events; when unset
462 * or cleared, release records that the pointer did not map to a valid cell.
463 */
464 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0,
465
466 /**
467 * Surface-space pointer position: GhosttySurfacePosition*.
468 *
469 * Valid for PRESS, DRAG, and AUTOSCROLL_TICK.
470 */
471 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION = 1,
472
473 /** Maximum repeat-click distance in pixels: double*. */
474 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_DISTANCE = 2,
475
476 /**
477 * Optional monotonic event time in nanoseconds: uint64_t*.
478 *
479 * If unset, press treats the event as untimed and only single-click behavior
480 * is available.
481 */
482 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_TIME_NS = 3,
483
484 /** Maximum interval between repeat clicks in nanoseconds: uint64_t*. */
485 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_INTERVAL_NS = 4,
486
487 /**
488 * Word-boundary codepoints: GhosttyCodepoints*.
489 *
490 * The codepoints are copied into event-owned storage when set. If unset,
491 * operations that need word boundaries use Ghostty's defaults.
492 *
493 * Valid for PRESS, DRAG, AUTOSCROLL_TICK, and DEEP_PRESS.
494 */
495 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5,
496
497 /**
498 * Selection behavior table: GhosttySelectionGestureBehaviors*.
499 *
500 * If unset, press uses the default behavior table: cell, word, line.
501 */
502 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS = 6,
503
504 /** Whether a drag or autoscroll tick should produce a rectangular selection: bool*. */
505 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_RECTANGLE = 7,
506
507 /** Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG and AUTOSCROLL_TICK. */
508 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY = 8,
509
510 /** Viewport coordinate for an autoscroll tick: GhosttyPointCoordinate*. Required for AUTOSCROLL_TICK. */
511 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT = 9,
512
513 GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
514} GhosttySelectionGestureEventOption;
515
516/**
517 * Create a reusable selection gesture event object.
518 *
519 * @param allocator Allocator, or NULL for the default allocator
520 * @param out_event Receives the created event handle
521 * @param type Event type. This is fixed for the lifetime of the event.
522 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_event is
523 * NULL or type is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails
524 *
525 * @ingroup selection
526 */
527GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_new(
528 const GhosttyAllocator* allocator,
529 GhosttySelectionGestureEvent* out_event,
530 GhosttySelectionGestureEventType type);
531
532/**
533 * Free a selection gesture event object.
534 *
535 * Passing NULL is allowed and is a no-op.
536 *
537 * @param event Selection gesture event handle to free
538 *
539 * @ingroup selection
540 */
541GHOSTTY_API void ghostty_selection_gesture_event_free(
542 GhosttySelectionGestureEvent event);
543
544/**
545 * Set or clear an option on a selection gesture event.
546 *
547 * The value type depends on option and is documented by
548 * GhosttySelectionGestureEventOption. Passing NULL for value clears the option.
549 *
550 * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE)
551 * @param option Event option to set or clear
552 * @param value Pointer to the input value for option, or NULL to clear
553 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if copying
554 * event-owned data fails, or GHOSTTY_INVALID_VALUE if event, option, or
555 * value is invalid
556 *
557 * @ingroup selection
558 */
559GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set(
560 GhosttySelectionGestureEvent event,
561 GhosttySelectionGestureEventOption option,
562 const void* value);
563
564/**
565 * Apply a selection gesture event and return the resulting selection snapshot.
566 *
567 * This dispatches to the gesture operation matching the event's fixed type.
568 * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS, the event must have
569 * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF set before calling this function.
570 * All other press options use their initialized defaults when unset or cleared.
571 *
572 * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE, only
573 * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF is valid. It is optional; if unset or
574 * cleared, release records that the pointer did not map to a valid cell. Release
575 * events update gesture state but do not produce a selection, so this function
576 * returns GHOSTTY_NO_VALUE after applying them.
577 *
578 * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG,
579 * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF and
580 * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position,
581 * rectangle, and word-boundary codepoints are optional and use initialized
582 * defaults when unset or cleared.
583 *
584 * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK,
585 * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT and
586 * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position,
587 * rectangle, and word-boundary codepoints are optional and use initialized
588 * defaults when unset or cleared.
589 *
590 * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS, only
591 * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS is valid. It is
592 * optional and uses initialized defaults when unset or cleared.
593 *
594 * The returned selection is not installed as the terminal's current selection.
595 * It is a snapshot with the same lifetime rules as GhosttySelection.
596 *
597 * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE)
598 * @param terminal Terminal used to interpret and update gesture state
599 * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE)
600 * @param[out] out_selection On success, receives the resulting selection. May
601 * be NULL to apply the event and discard the selection result.
602 * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the event does not
603 * currently produce a selection, GHOSTTY_OUT_OF_MEMORY if tracking
604 * gesture state fails, or GHOSTTY_INVALID_VALUE if gesture, terminal,
605 * event, or required event data is invalid
606 *
607 * @ingroup selection
608 */
609GHOSTTY_API GhosttyResult ghostty_selection_gesture_event(
610 GhosttySelectionGesture gesture,
611 GhosttyTerminal terminal,
612 GhosttySelectionGestureEvent event,
613 GhosttySelection* out_selection);
614
615/**
616 * Create a selection gesture object.
617 *
618 * The gesture stores mutable state for terminal text selection gestures. The
619 * gesture is not bound to a terminal at creation time; terminal-dependent APIs
620 * take the terminal explicitly.
621 *
622 * @param allocator Allocator, or NULL for the default allocator
623 * @param out_gesture Receives the created gesture handle
624 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_gesture is
625 * NULL, or GHOSTTY_OUT_OF_MEMORY if allocation fails
626 *
627 * @ingroup selection
628 */
629GHOSTTY_API GhosttyResult ghostty_selection_gesture_new(
630 const GhosttyAllocator* allocator,
631 GhosttySelectionGesture* out_gesture);
632
633/**
634 * Free a selection gesture object.
635 *
636 * This releases any tracked terminal references owned by the gesture using the
637 * provided terminal, then frees the gesture object. Passing NULL for gesture is
638 * allowed and is a no-op.
639 *
640 * If the terminal is still alive, pass the terminal most recently used with the
641 * gesture so any tracked terminal references can be released correctly. If the
642 * terminal has already been freed, pass NULL for terminal; the terminal's page
643 * storage has already released the underlying tracked references, so the
644 * gesture wrapper can be safely discarded without touching the stale terminal
645 * state.
646 *
647 * @param gesture Selection gesture handle to free
648 * @param terminal Terminal used to release tracked gesture state, or NULL if
649 * the terminal has already been freed
650 *
651 * @ingroup selection
652 */
653GHOSTTY_API void ghostty_selection_gesture_free(
654 GhosttySelectionGesture gesture,
655 GhosttyTerminal terminal);
656
657/**
658 * Reset any active selection gesture state.
659 *
660 * This cancels the active click sequence and releases any tracked terminal
661 * references owned by the gesture without freeing the gesture object.
662 * Passing NULL is allowed and is a no-op.
663 *
664 * @param gesture Selection gesture handle to reset
665 * @param terminal Terminal used to release tracked gesture state
666 *
667 * @ingroup selection
668 */
669GHOSTTY_API void ghostty_selection_gesture_reset(
670 GhosttySelectionGesture gesture,
671 GhosttyTerminal terminal);
672
673/**
674 * Read data from a selection gesture.
675 *
676 * The type of value depends on data and is documented by
677 * GhosttySelectionGestureData. For GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR,
678 * the returned GhosttyGridRef is an untracked snapshot with normal grid-ref
679 * lifetime rules.
680 *
681 * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE)
682 * @param terminal Terminal used to validate terminal-backed gesture state
683 * @param data Data field to read
684 * @param value Output pointer whose type depends on data
685 * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data
686 * has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, data, or
687 * value is invalid
688 *
689 * @ingroup selection
690 */
691GHOSTTY_API GhosttyResult ghostty_selection_gesture_get(
692 GhosttySelectionGesture gesture,
693 GhosttyTerminal terminal,
694 GhosttySelectionGestureData data,
695 void* value);
696
697/**
698 * Read multiple data fields from a selection gesture in a single call.
699 *
700 * This is an optimization over calling ghostty_selection_gesture_get() multiple
701 * times. Each entry in values must point to storage of the type documented by
702 * the corresponding GhosttySelectionGestureData key.
703 *
704 * If any individual read fails, the function returns that error and writes the
705 * index of the failing key to out_written when out_written is non-NULL. On
706 * success, out_written receives count when non-NULL.
707 *
708 * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE)
709 * @param terminal Terminal used to validate terminal-backed gesture state
710 * @param count Number of data fields to read
711 * @param keys Data fields to read (must not be NULL)
712 * @param values Output pointers corresponding to keys (must not be NULL)
713 * @param out_written Optional number of fields read, or failing index on error
714 * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if a requested data
715 * field has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal,
716 * keys, values, or a value pointer is invalid
717 *
718 * @ingroup selection
719 */
720GHOSTTY_API GhosttyResult ghostty_selection_gesture_get_multi(
721 GhosttySelectionGesture gesture,
722 GhosttyTerminal terminal,
723 size_t count,
724 const GhosttySelectionGestureData* keys,
725 void** values,
726 size_t* out_written);
727
728/**
729 * Derive a word selection snapshot from a terminal grid reference.
730 *
731 * The returned selection is not installed as the terminal's current
732 * selection. It is a snapshot with the same lifetime rules as GhosttySelection.
733 *
734 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
735 * @param options Word-selection options
736 * @param[out] out_selection On success, receives the derived selection
737 * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has
738 * no selectable word content, or GHOSTTY_INVALID_VALUE if the
739 * terminal, options, ref, codepoint pointer, or output pointer are
740 * invalid.
741 *
742 * @ingroup selection
743 */
744GHOSTTY_API GhosttyResult ghostty_terminal_select_word(
745 GhosttyTerminal terminal,
746 const GhosttyTerminalSelectWordOptions* options,
747 GhosttySelection* out_selection);
748
749/**
750 * Derive the nearest word selection snapshot between two terminal grid refs.
751 *
752 * Starting at options->start, this searches toward options->end (inclusive)
753 * and returns the first selectable word found using Ghostty's word-selection
754 * rules.
755 *
756 * This is useful for implementing double-click-and-drag selection in a UI. If
757 * a user double-clicks one word and drags across spaces or punctuation toward
758 * another word, selecting only the word directly under the current pointer can
759 * flicker or collapse when the pointer is between words. Instead, ask for the
760 * nearest word between the original click and the drag point, ask again in the
761 * reverse direction, and combine the two word bounds into the drag selection.
762 *
763 * @snippet c-vt-selection/src/main.c selection-word-between
764 *
765 * The returned selection is not installed as the terminal's current
766 * selection. It is a snapshot with the same lifetime rules as GhosttySelection.
767 *
768 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
769 * @param options Word-between-selection options
770 * @param[out] out_selection On success, receives the derived selection
771 * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no
772 * selectable word content between the valid refs, or
773 * GHOSTTY_INVALID_VALUE if the terminal, options, refs, codepoint
774 * pointer, or output pointer are invalid.
775 *
776 * @ingroup selection
777 */
778GHOSTTY_API GhosttyResult ghostty_terminal_select_word_between(
779 GhosttyTerminal terminal,
780 const GhosttyTerminalSelectWordBetweenOptions* options,
781 GhosttySelection* out_selection);
782
783/**
784 * Derive a line selection snapshot from a terminal grid reference.
785 *
786 * The returned selection is not installed as the terminal's current
787 * selection. It is a snapshot with the same lifetime rules as GhosttySelection.
788 *
789 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
790 * @param options Line-selection options
791 * @param[out] out_selection On success, receives the derived selection
792 * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has
793 * no selectable line content, or GHOSTTY_INVALID_VALUE if the
794 * terminal, options, ref, codepoint pointer, or output pointer are
795 * invalid.
796 *
797 * @ingroup selection
798 */
799GHOSTTY_API GhosttyResult ghostty_terminal_select_line(
800 GhosttyTerminal terminal,
801 const GhosttyTerminalSelectLineOptions* options,
802 GhosttySelection* out_selection);
803
804/**
805 * Derive a selection snapshot covering all selectable terminal content.
806 *
807 * The returned selection is not installed as the terminal's current
808 * selection. It is a snapshot with the same lifetime rules as GhosttySelection.
809 *
810 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
811 * @param[out] out_selection On success, receives the derived selection
812 * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no
813 * selectable content, or GHOSTTY_INVALID_VALUE if the terminal or
814 * output pointer is invalid.
815 *
816 * @ingroup selection
817 */
818GHOSTTY_API GhosttyResult ghostty_terminal_select_all(
819 GhosttyTerminal terminal,
820 GhosttySelection* out_selection);
821
822/**
823 * Derive a command-output selection snapshot from a terminal grid reference.
824 *
825 * The returned selection is not installed as the terminal's current
826 * selection. It is a snapshot with the same lifetime rules as GhosttySelection.
827 *
828 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
829 * @param ref Grid reference within command output to select
830 * @param[out] out_selection On success, receives the derived selection
831 * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref is
832 * not selectable command output, or GHOSTTY_INVALID_VALUE if the
833 * terminal, ref, or output pointer is invalid.
834 *
835 * @ingroup selection
836 */
837GHOSTTY_API GhosttyResult ghostty_terminal_select_output(
838 GhosttyTerminal terminal,
839 GhosttyGridRef ref,
840 GhosttySelection* out_selection);
841
842/**
843 * Format a terminal selection into a caller-provided buffer.
844 *
845 * This is a one-shot convenience API for formatting either the terminal's
846 * active selection or a caller-provided GhosttySelection without explicitly
847 * creating a GhosttyFormatter.
848 *
849 * Pass NULL for buf to query the required output size. In that case,
850 * out_written receives the required size and the function returns
851 * GHOSTTY_OUT_OF_SPACE.
852 *
853 * If buf is too small, the function returns GHOSTTY_OUT_OF_SPACE and writes
854 * the required size to out_written. The caller can then retry with a larger
855 * buffer.
856 *
857 * If options.selection is NULL and the terminal has no active selection, the
858 * function returns GHOSTTY_NO_VALUE.
859 *
860 * @param terminal The terminal to read from (must not be NULL)
861 * @param options Selection formatting options
862 * @param buf Output buffer, or NULL to query required size
863 * @param buf_len Length of buf in bytes
864 * @param out_written Number of bytes written, or required size on
865 * GHOSTTY_OUT_OF_SPACE (must not be NULL)
866 * @return GHOSTTY_SUCCESS on success, or an error code on failure
867 *
868 * @ingroup selection
869 */
870GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_buf(
871 GhosttyTerminal terminal,
872 GhosttyTerminalSelectionFormatOptions options,
873 uint8_t* buf,
874 size_t buf_len,
875 size_t* out_written);
876
877/**
878 * Format a terminal selection into an allocated buffer.
879 *
880 * This is a one-shot convenience API for formatting either the terminal's
881 * active selection or a caller-provided GhosttySelection without explicitly
882 * creating a GhosttyFormatter.
883 *
884 * The returned buffer is allocated using allocator, or the default allocator
885 * if NULL is passed. The caller owns the returned buffer and must free it with
886 * ghostty_free(), passing the same allocator and returned length.
887 *
888 * The returned bytes are not NUL-terminated. This supports plain text, VT, and
889 * HTML uniformly as byte output.
890 *
891 * If options.selection is NULL and the terminal has no active selection, the
892 * function returns GHOSTTY_NO_VALUE and leaves out_ptr as NULL and out_len as 0.
893 *
894 * @param terminal The terminal to read from (must not be NULL)
895 * @param allocator Allocator used for the returned buffer, or NULL for the default allocator
896 * @param options Selection formatting options
897 * @param out_ptr Receives the allocated output buffer (must not be NULL)
898 * @param out_len Receives the output length in bytes (must not be NULL)
899 * @return GHOSTTY_SUCCESS on success, or an error code on failure
900 *
901 * @ingroup selection
902 */
903GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_alloc(
904 GhosttyTerminal terminal,
905 const GhosttyAllocator* allocator,
906 GhosttyTerminalSelectionFormatOptions options,
907 uint8_t** out_ptr,
908 size_t* out_len);
909
910/**
911 * Adjust a selection snapshot using terminal selection semantics.
912 *
913 * This mutates the caller-provided GhosttySelection in place. The logical end
914 * endpoint is always moved, regardless of whether the selection is forward or
915 * reversed visually. The input selection remains a snapshot: after adjustment,
916 * call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it
917 * as the terminal-owned selection if desired.
918 *
919 * The selection's start and end grid refs must both be valid untracked
920 * snapshots for the given terminal's currently active screen. In practice,
921 * they must come from that terminal and screen, and no mutating terminal call
922 * may have occurred since the refs were produced or reconstructed from
923 * tracked refs. Passing refs from another terminal, another screen, or stale
924 * refs violates this precondition.
925 *
926 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
927 * @param selection Selection snapshot to adjust in place
928 * @param adjustment The adjustment operation to apply
929 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,
930 * selection, or adjustment are invalid. Selection reference validity
931 * is a precondition and is not checked.
932 *
933 * @ingroup selection
934 */
935GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust(
936 GhosttyTerminal terminal,
937 GhosttySelection* selection,
938 GhosttySelectionAdjust adjustment);
939
940/**
941 * Get the current endpoint ordering of a selection snapshot.
942 *
943 * The selection's start and end grid refs must both be valid untracked
944 * snapshots for the given terminal's currently active screen. In practice,
945 * they must come from that terminal and screen, and no mutating terminal call
946 * may have occurred since the refs were produced or reconstructed from
947 * tracked refs. Passing refs from another terminal, another screen, or stale
948 * refs violates this precondition.
949 *
950 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
951 * @param selection Selection snapshot to inspect
952 * @param[out] out_order On success, receives the selection order
953 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,
954 * selection, or output pointer are invalid. Selection reference
955 * validity is a precondition and is not checked.
956 *
957 * @ingroup selection
958 */
959GHOSTTY_API GhosttyResult ghostty_terminal_selection_order(
960 GhosttyTerminal terminal,
961 const GhosttySelection* selection,
962 GhosttySelectionOrder* out_order);
963
964/**
965 * Return a selection snapshot with endpoints ordered as requested.
966 *
967 * Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds,
968 * and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds.
969 * Mirrored desired orders are accepted but normalized the same as forward.
970 * The output selection is a fresh untracked snapshot and is not installed as
971 * the terminal's current selection.
972 *
973 * The selection's start and end grid refs must both be valid untracked
974 * snapshots for the given terminal's currently active screen. In practice,
975 * they must come from that terminal and screen, and no mutating terminal call
976 * may have occurred since the refs were produced or reconstructed from
977 * tracked refs. Passing refs from another terminal, another screen, or stale
978 * refs violates this precondition.
979 *
980 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
981 * @param selection Selection snapshot to order
982 * @param desired Desired endpoint order
983 * @param[out] out_selection On success, receives the ordered selection
984 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,
985 * selection, desired order, or output pointer are invalid. Selection
986 * reference validity is a precondition and is not checked.
987 *
988 * @ingroup selection
989 */
990GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered(
991 GhosttyTerminal terminal,
992 const GhosttySelection* selection,
993 GhosttySelectionOrder desired,
994 GhosttySelection* out_selection);
995
996/**
997 * Test whether a terminal point is inside a selection snapshot.
998 *
999 * This uses the same selection semantics as the terminal, including
1000 * rectangular/block selections and linear selections spanning multiple rows.
1001 *
1002 * The selection's start and end grid refs must both be valid untracked
1003 * snapshots for the given terminal's currently active screen. In practice,
1004 * they must come from that terminal and screen, and no mutating terminal call
1005 * may have occurred since the refs were produced or reconstructed from
1006 * tracked refs. Passing refs from another terminal, another screen, or stale
1007 * refs violates this precondition.
1008 *
1009 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
1010 * @param selection Selection snapshot to inspect
1011 * @param point Point to test for containment
1012 * @param[out] out_contains On success, receives whether point is inside selection
1013 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,
1014 * selection, point, or output pointer are invalid. Selection reference
1015 * validity is a precondition and is not checked.
1016 *
1017 * @ingroup selection
1018 */
1019GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains(
1020 GhosttyTerminal terminal,
1021 const GhosttySelection* selection,
1022 GhosttyPoint point,
1023 bool* out_contains);
1024
1025/**
1026 * Test whether two selection snapshots are equal.
1027 *
1028 * Equality uses the terminal's internal selection semantics: both endpoint
1029 * pins must match and both selections must have the same rectangular/block
1030 * state. This avoids requiring callers to compare raw GhosttyGridRef internals.
1031 *
1032 * Both selections' start and end grid refs must be valid untracked snapshots
1033 * for the given terminal's currently active screen. In practice, they must
1034 * come from that terminal and screen, and no mutating terminal call may have
1035 * occurred since the refs were produced or reconstructed from tracked refs.
1036 * Passing refs from another terminal, another screen, or stale refs violates
1037 * this precondition.
1038 *
1039 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
1040 * @param a First selection snapshot to compare
1041 * @param b Second selection snapshot to compare
1042 * @param[out] out_equal On success, receives whether the selections are equal
1043 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal,
1044 * selections, or output pointer are invalid. Selection reference
1045 * validity is a precondition and is not checked.
1046 *
1047 * @ingroup selection
1048 */
1049GHOSTTY_API GhosttyResult ghostty_terminal_selection_equal(
1050 GhosttyTerminal terminal,
1051 const GhosttySelection* a,
1052 const GhosttySelection* b,
1053 bool* out_equal);
1054
1055/** @} */
1056
1057#ifdef __cplusplus
1058}
1059#endif
1060
1061#endif /* GHOSTTY_VT_SELECTION_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sgr.h created+350
...@@ -0,0 +1,350 @@
1/**
2 * @file sgr.h
3 *
4 * SGR (Select Graphic Rendition) attribute parsing and handling.
5 */
6
7#ifndef GHOSTTY_VT_SGR_H
8#define GHOSTTY_VT_SGR_H
9
10/** @defgroup sgr SGR Parser
11 *
12 * SGR (Select Graphic Rendition) attribute parser.
13 *
14 * SGR sequences are the syntax used to set styling attributes such as
15 * bold, italic, underline, and colors for text in terminal emulators.
16 * For example, you may be familiar with sequences like `ESC[1;31m`. The
17 * `1;31` is the SGR attribute list.
18 *
19 * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`)
20 * and returns individual text attributes like bold, italic, colors, etc.
21 * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed,
22 * and handles various color formats including 8-color, 16-color, 256-color,
23 * X11 named colors, and RGB in multiple formats.
24 *
25 * ## Basic Usage
26 *
27 * 1. Create a parser instance with ghostty_sgr_new()
28 * 2. Set SGR parameters with ghostty_sgr_set_params()
29 * 3. Iterate through attributes using ghostty_sgr_next()
30 * 4. Free the parser with ghostty_sgr_free() when done
31 *
32 * ## Example
33 *
34 * @snippet c-vt-sgr/src/main.c sgr-basic
35 *
36 * @{
37 */
38
39#include <ghostty/vt/allocator.h>
40#include <ghostty/vt/color.h>
41#include <ghostty/vt/types.h>
42#include <stdbool.h>
43#include <stddef.h>
44#include <stdint.h>
45
46#ifdef __cplusplus
47extern "C" {
48#endif
49
50/**
51 * SGR attribute tags.
52 *
53 * These values identify the type of an SGR attribute in a tagged union.
54 * Use the tag to determine which field in the attribute value union to access.
55 *
56 * @ingroup sgr
57 */
58typedef enum GHOSTTY_ENUM_TYPED {
59 GHOSTTY_SGR_ATTR_UNSET = 0,
60 GHOSTTY_SGR_ATTR_UNKNOWN = 1,
61 GHOSTTY_SGR_ATTR_BOLD = 2,
62 GHOSTTY_SGR_ATTR_RESET_BOLD = 3,
63 GHOSTTY_SGR_ATTR_ITALIC = 4,
64 GHOSTTY_SGR_ATTR_RESET_ITALIC = 5,
65 GHOSTTY_SGR_ATTR_FAINT = 6,
66 GHOSTTY_SGR_ATTR_UNDERLINE = 7,
67 GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 8,
68 GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 9,
69 GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 10,
70 GHOSTTY_SGR_ATTR_OVERLINE = 11,
71 GHOSTTY_SGR_ATTR_RESET_OVERLINE = 12,
72 GHOSTTY_SGR_ATTR_BLINK = 13,
73 GHOSTTY_SGR_ATTR_RESET_BLINK = 14,
74 GHOSTTY_SGR_ATTR_INVERSE = 15,
75 GHOSTTY_SGR_ATTR_RESET_INVERSE = 16,
76 GHOSTTY_SGR_ATTR_INVISIBLE = 17,
77 GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 18,
78 GHOSTTY_SGR_ATTR_STRIKETHROUGH = 19,
79 GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 20,
80 GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 21,
81 GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 22,
82 GHOSTTY_SGR_ATTR_BG_8 = 23,
83 GHOSTTY_SGR_ATTR_FG_8 = 24,
84 GHOSTTY_SGR_ATTR_RESET_FG = 25,
85 GHOSTTY_SGR_ATTR_RESET_BG = 26,
86 GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 27,
87 GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 28,
88 GHOSTTY_SGR_ATTR_BG_256 = 29,
89 GHOSTTY_SGR_ATTR_FG_256 = 30,
90 GHOSTTY_SGR_ATTR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
91} GhosttySgrAttributeTag;
92
93/**
94 * Underline style types.
95 *
96 * @ingroup sgr
97 */
98typedef enum GHOSTTY_ENUM_TYPED {
99 GHOSTTY_SGR_UNDERLINE_NONE = 0,
100 GHOSTTY_SGR_UNDERLINE_SINGLE = 1,
101 GHOSTTY_SGR_UNDERLINE_DOUBLE = 2,
102 GHOSTTY_SGR_UNDERLINE_CURLY = 3,
103 GHOSTTY_SGR_UNDERLINE_DOTTED = 4,
104 GHOSTTY_SGR_UNDERLINE_DASHED = 5,
105 GHOSTTY_SGR_UNDERLINE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
106} GhosttySgrUnderline;
107
108/**
109 * Unknown SGR attribute data.
110 *
111 * Contains the full parameter list and the partial list where parsing
112 * encountered an unknown or invalid sequence.
113 *
114 * @ingroup sgr
115 */
116typedef struct {
117 const uint16_t* full_ptr;
118 size_t full_len;
119 const uint16_t* partial_ptr;
120 size_t partial_len;
121} GhosttySgrUnknown;
122
123/**
124 * SGR attribute value union.
125 *
126 * This union contains all possible attribute values. Use the tag field
127 * to determine which union member is active. Attributes without associated
128 * data (like bold, italic) don't use the union value.
129 *
130 * @ingroup sgr
131 */
132typedef union {
133 GhosttySgrUnknown unknown;
134 GhosttySgrUnderline underline;
135 GhosttyColorRgb underline_color;
136 GhosttyColorPaletteIndex underline_color_256;
137 GhosttyColorRgb direct_color_fg;
138 GhosttyColorRgb direct_color_bg;
139 GhosttyColorPaletteIndex bg_8;
140 GhosttyColorPaletteIndex fg_8;
141 GhosttyColorPaletteIndex bright_bg_8;
142 GhosttyColorPaletteIndex bright_fg_8;
143 GhosttyColorPaletteIndex bg_256;
144 GhosttyColorPaletteIndex fg_256;
145 uint64_t _padding[8];
146} GhosttySgrAttributeValue;
147
148/**
149 * SGR attribute (tagged union).
150 *
151 * A complete SGR attribute with both its type tag and associated value.
152 * Always check the tag field to determine which value union member is valid.
153 *
154 * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be
155 * identified by tag alone; the value union is not used for these and
156 * the memory in the value field is undefined.
157 *
158 * @ingroup sgr
159 */
160typedef struct {
161 GhosttySgrAttributeTag tag;
162 GhosttySgrAttributeValue value;
163} GhosttySgrAttribute;
164
165/**
166 * Create a new SGR parser instance.
167 *
168 * Creates a new SGR (Select Graphic Rendition) parser using the provided
169 * allocator. The parser must be freed using ghostty_sgr_free() when
170 * no longer needed.
171 *
172 * @param allocator Pointer to the allocator to use for memory management, or
173 * NULL to use the default allocator
174 * @param parser Pointer to store the created parser handle
175 * @return GHOSTTY_SUCCESS on success, or an error code on failure
176 *
177 * @ingroup sgr
178 */
179GHOSTTY_API GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator,
180 GhosttySgrParser* parser);
181
182/**
183 * Free an SGR parser instance.
184 *
185 * Releases all resources associated with the SGR parser. After this call,
186 * the parser handle becomes invalid and must not be used. This includes
187 * any attributes previously returned by ghostty_sgr_next().
188 *
189 * @param parser The parser handle to free (may be NULL)
190 *
191 * @ingroup sgr
192 */
193GHOSTTY_API void ghostty_sgr_free(GhosttySgrParser parser);
194
195/**
196 * Reset an SGR parser instance to the beginning of the parameter list.
197 *
198 * Resets the parser's iteration state without clearing the parameters.
199 * After calling this, ghostty_sgr_next() will start from the beginning
200 * of the parameter list again.
201 *
202 * @param parser The parser handle to reset, must not be NULL
203 *
204 * @ingroup sgr
205 */
206GHOSTTY_API void ghostty_sgr_reset(GhosttySgrParser parser);
207
208/**
209 * Set SGR parameters for parsing.
210 *
211 * Sets the SGR parameter list to parse. Parameters are the numeric values
212 * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}).
213 *
214 * The separators array optionally specifies the separator type for each
215 * parameter position. Each byte should be either ';' for semicolon or ':'
216 * for colon. This is needed for certain color formats that use colon
217 * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator
218 * values are treated as semicolons. The separators array must have the same
219 * length as the params array, if it is not NULL.
220 *
221 * If separators is NULL, all parameters are assumed to be semicolon-separated.
222 *
223 * This function makes an internal copy of the parameter and separator data,
224 * so the caller can safely free or modify the input arrays after this call.
225 *
226 * After calling this function, the parser is automatically reset and ready
227 * to iterate from the beginning.
228 *
229 * @param parser The parser handle, must not be NULL
230 * @param params Array of SGR parameter values
231 * @param separators Optional array of separator characters (';' or ':'), or
232 * NULL
233 * @param len Number of parameters (and separators if provided)
234 * @return GHOSTTY_SUCCESS on success, or an error code on failure
235 *
236 * @ingroup sgr
237 */
238GHOSTTY_API GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser,
239 const uint16_t* params,
240 const char* separators,
241 size_t len);
242
243/**
244 * Get the next SGR attribute.
245 *
246 * Parses and returns the next attribute from the parameter list.
247 * Call this function repeatedly until it returns false to process
248 * all attributes in the sequence.
249 *
250 * @param parser The parser handle, must not be NULL
251 * @param attr Pointer to store the next attribute
252 * @return true if an attribute was returned, false if no more attributes
253 *
254 * @ingroup sgr
255 */
256GHOSTTY_API bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr);
257
258/**
259 * Get the full parameter list from an unknown SGR attribute.
260 *
261 * This function retrieves the full parameter list that was provided to the
262 * parser when an unknown attribute was encountered. Primarily useful in
263 * WebAssembly environments where accessing struct fields directly is difficult.
264 *
265 * @param unknown The unknown attribute data
266 * @param ptr Pointer to store the pointer to the parameter array (may be NULL)
267 * @return The length of the full parameter array
268 *
269 * @ingroup sgr
270 */
271GHOSTTY_API size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown,
272 const uint16_t** ptr);
273
274/**
275 * Get the partial parameter list from an unknown SGR attribute.
276 *
277 * This function retrieves the partial parameter list where parsing stopped
278 * when an unknown attribute was encountered. Primarily useful in WebAssembly
279 * environments where accessing struct fields directly is difficult.
280 *
281 * @param unknown The unknown attribute data
282 * @param ptr Pointer to store the pointer to the parameter array (may be NULL)
283 * @return The length of the partial parameter array
284 *
285 * @ingroup sgr
286 */
287GHOSTTY_API size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown,
288 const uint16_t** ptr);
289
290/**
291 * Get the tag from an SGR attribute.
292 *
293 * This function extracts the tag that identifies which type of attribute
294 * this is. Primarily useful in WebAssembly environments where accessing
295 * struct fields directly is difficult.
296 *
297 * @param attr The SGR attribute
298 * @return The attribute tag
299 *
300 * @ingroup sgr
301 */
302GHOSTTY_API GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr);
303
304/**
305 * Get the value from an SGR attribute.
306 *
307 * This function returns a pointer to the value union from an SGR attribute. Use
308 * the tag to determine which field of the union is valid. Primarily useful in
309 * WebAssembly environments where accessing struct fields directly is difficult.
310 *
311 * @param attr Pointer to the SGR attribute
312 * @return Pointer to the attribute value union
313 *
314 * @ingroup sgr
315 */
316GHOSTTY_API GhosttySgrAttributeValue* ghostty_sgr_attribute_value(
317 GhosttySgrAttribute* attr);
318
319#ifdef __wasm__
320/**
321 * Allocate memory for an SGR attribute (WebAssembly only).
322 *
323 * This is a convenience function for WebAssembly environments to allocate
324 * memory for an SGR attribute structure that can be passed to ghostty_sgr_next.
325 *
326 * @return Pointer to the allocated attribute structure
327 *
328 * @ingroup wasm
329 */
330GHOSTTY_API GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void);
331
332/**
333 * Free memory for an SGR attribute (WebAssembly only).
334 *
335 * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute.
336 *
337 * @param attr Pointer to the attribute structure to free
338 *
339 * @ingroup wasm
340 */
341GHOSTTY_API void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr);
342#endif
343
344#ifdef __cplusplus
345}
346#endif
347
348/** @} */
349
350#endif /* GHOSTTY_VT_SGR_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/size_report.h created+101
...@@ -0,0 +1,101 @@
1/**
2 * @file size_report.h
3 *
4 * Size report encoding - encode terminal size reports into escape sequences.
5 */
6
7#ifndef GHOSTTY_VT_SIZE_REPORT_H
8#define GHOSTTY_VT_SIZE_REPORT_H
9
10/** @defgroup size_report Size Report Encoding
11 *
12 * Utilities for encoding terminal size reports into escape sequences,
13 * supporting in-band size reports (mode 2048) and XTWINOPS responses
14 * (CSI 14 t, CSI 16 t, CSI 18 t).
15 *
16 * ## Basic Usage
17 *
18 * Use ghostty_size_report_encode() to encode a size report into a
19 * caller-provided buffer. If the buffer is too small, the function
20 * returns GHOSTTY_OUT_OF_SPACE and sets the required size in the
21 * output parameter.
22 *
23 * ## Example
24 *
25 * @snippet c-vt-size-report/src/main.c size-report-encode
26 *
27 * @{
28 */
29
30#include <stddef.h>
31#include <stdint.h>
32#include <ghostty/vt/types.h>
33
34#ifdef __cplusplus
35extern "C" {
36#endif
37
38/**
39 * Size report style.
40 *
41 * Determines the output format for the terminal size report.
42 */
43typedef enum GHOSTTY_ENUM_TYPED {
44 /** In-band size report (mode 2048): ESC [ 48 ; rows ; cols ; height ; width t */
45 GHOSTTY_SIZE_REPORT_MODE_2048 = 0,
46 /** XTWINOPS text area size in pixels: ESC [ 4 ; height ; width t */
47 GHOSTTY_SIZE_REPORT_CSI_14_T = 1,
48 /** XTWINOPS cell size in pixels: ESC [ 6 ; height ; width t */
49 GHOSTTY_SIZE_REPORT_CSI_16_T = 2,
50 /** XTWINOPS text area size in characters: ESC [ 8 ; rows ; cols t */
51 GHOSTTY_SIZE_REPORT_CSI_18_T = 3,
52 GHOSTTY_SIZE_REPORT_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
53} GhosttySizeReportStyle;
54
55/**
56 * Terminal size information for encoding size reports.
57 */
58typedef struct {
59 /** Terminal row count in cells. */
60 uint16_t rows;
61 /** Terminal column count in cells. */
62 uint16_t columns;
63 /** Width of a single terminal cell in pixels. */
64 uint32_t cell_width;
65 /** Height of a single terminal cell in pixels. */
66 uint32_t cell_height;
67} GhosttySizeReportSize;
68
69/**
70 * Encode a terminal size report into an escape sequence.
71 *
72 * Encodes a size report in the format specified by @p style into the
73 * provided buffer.
74 *
75 * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE
76 * and writes the required buffer size to @p out_written. The caller can
77 * then retry with a sufficiently sized buffer.
78 *
79 * @param style The size report format to encode
80 * @param size Terminal size information
81 * @param buf Output buffer to write the encoded sequence into (may be NULL)
82 * @param buf_len Size of the output buffer in bytes
83 * @param[out] out_written On success, the number of bytes written. On
84 * GHOSTTY_OUT_OF_SPACE, the required buffer size.
85 * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer
86 * is too small
87 */
88GHOSTTY_API GhosttyResult ghostty_size_report_encode(
89 GhosttySizeReportStyle style,
90 GhosttySizeReportSize size,
91 char* buf,
92 size_t buf_len,
93 size_t* out_written);
94
95#ifdef __cplusplus
96}
97#endif
98
99/** @} */
100
101#endif /* GHOSTTY_VT_SIZE_REPORT_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/style.h created+139
...@@ -0,0 +1,139 @@
1/**
2 * @file style.h
3 *
4 * Terminal cell style types.
5 */
6
7#ifndef GHOSTTY_VT_STYLE_H
8#define GHOSTTY_VT_STYLE_H
9
10#include <ghostty/vt/color.h>
11#include <ghostty/vt/types.h>
12#include <stdbool.h>
13#include <stddef.h>
14#include <stdint.h>
15
16#ifdef __cplusplus
17extern "C" {
18#endif
19
20/** @defgroup style Style
21 *
22 * Terminal cell style attributes.
23 *
24 * A style describes the visual attributes of a terminal cell, including
25 * foreground, background, and underline colors, as well as flags for
26 * bold, italic, underline, and other text decorations.
27 *
28 * @{
29 */
30
31/**
32 * Style identifier type.
33 *
34 * Used to look up the full style from a grid reference.
35 * Obtain this from a cell via GHOSTTY_CELL_DATA_STYLE_ID.
36 *
37 * @ingroup style
38 */
39typedef uint16_t GhosttyStyleId;
40
41/**
42 * Style color tags.
43 *
44 * These values identify the type of color in a style color.
45 * Use the tag to determine which field in the color value union to access.
46 *
47 * @ingroup style
48 */
49typedef enum GHOSTTY_ENUM_TYPED {
50 GHOSTTY_STYLE_COLOR_NONE = 0,
51 GHOSTTY_STYLE_COLOR_PALETTE = 1,
52 GHOSTTY_STYLE_COLOR_RGB = 2,
53 GHOSTTY_STYLE_COLOR_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
54 } GhosttyStyleColorTag;
55
56/**
57 * Style color value union.
58 *
59 * Use the tag to determine which field is active.
60 *
61 * @ingroup style
62 */
63typedef union {
64 GhosttyColorPaletteIndex palette;
65 GhosttyColorRgb rgb;
66 uint64_t _padding;
67} GhosttyStyleColorValue;
68
69/**
70 * Style color (tagged union).
71 *
72 * A color used in a style attribute. Can be unset (none), a palette
73 * index, or a direct RGB value.
74 *
75 * @ingroup style
76 */
77typedef struct {
78 GhosttyStyleColorTag tag;
79 GhosttyStyleColorValue value;
80} GhosttyStyleColor;
81
82/**
83 * Terminal cell style.
84 *
85 * Describes the complete visual style for a terminal cell, including
86 * foreground, background, and underline colors, as well as text
87 * decoration flags. The underline field uses the same values as
88 * GhosttySgrUnderline.
89 *
90 * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
91 *
92 * @ingroup style
93 */
94typedef struct {
95 size_t size;
96 GhosttyStyleColor fg_color;
97 GhosttyStyleColor bg_color;
98 GhosttyStyleColor underline_color;
99 bool bold;
100 bool italic;
101 bool faint;
102 bool blink;
103 bool inverse;
104 bool invisible;
105 bool strikethrough;
106 bool overline;
107 int underline; /**< One of GHOSTTY_SGR_UNDERLINE_* values */
108} GhosttyStyle;
109
110/**
111 * Get the default style.
112 *
113 * Initializes the style to the default values (no colors, no flags).
114 *
115 * @param style Pointer to the style to initialize
116 *
117 * @ingroup style
118 */
119GHOSTTY_API void ghostty_style_default(GhosttyStyle* style);
120
121/**
122 * Check if a style is the default style.
123 *
124 * Returns true if all colors are unset and all flags are off.
125 *
126 * @param style Pointer to the style to check
127 * @return true if the style is the default style
128 *
129 * @ingroup style
130 */
131GHOSTTY_API bool ghostty_style_is_default(const GhosttyStyle* style);
132
133#ifdef __cplusplus
134}
135#endif
136
137/** @} */
138
139#endif /* GHOSTTY_VT_STYLE_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sys.h created+210
...@@ -0,0 +1,210 @@
1/**
2 * @file sys.h
3 *
4 * System interface - runtime-swappable implementations for external dependencies.
5 */
6
7#ifndef GHOSTTY_VT_SYS_H
8#define GHOSTTY_VT_SYS_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/types.h>
14#include <ghostty/vt/allocator.h>
15
16/** @defgroup sys System Interface
17 *
18 * Runtime-swappable function pointers for operations that depend on
19 * external implementations (e.g. image decoding).
20 *
21 * These are process-global settings that must be configured at startup
22 * before any terminal functionality that depends on them is used.
23 * Setting these enables various optional features of the terminal. For
24 * example, setting a PNG decoder enables PNG image support in the Kitty
25 * Graphics Protocol.
26 *
27 * Use ghostty_sys_set() with a `GhosttySysOption` to install or clear
28 * an implementation. Passing NULL as the value clears the implementation
29 * and disables the corresponding feature.
30 *
31 * ## Example
32 *
33 * ### Defining a PNG decode callback
34 * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png
35 *
36 * ### Installing the callback and sending a PNG image
37 * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main
38 *
39 * @{
40 */
41
42#ifdef __cplusplus
43extern "C" {
44#endif
45
46/**
47 * Result of decoding an image.
48 *
49 * The `data` buffer must be allocated through the allocator provided to
50 * the decode callback. The library takes ownership and will free it
51 * with the same allocator.
52 */
53typedef struct {
54 /** Image width in pixels. */
55 uint32_t width;
56
57 /** Image height in pixels. */
58 uint32_t height;
59
60 /** Pointer to the decoded RGBA pixel data. */
61 uint8_t* data;
62
63 /** Length of the pixel data in bytes. */
64 size_t data_len;
65} GhosttySysImage;
66
67/**
68 * Log severity levels for the log callback.
69 */
70typedef enum GHOSTTY_ENUM_TYPED {
71 GHOSTTY_SYS_LOG_LEVEL_ERROR = 0,
72 GHOSTTY_SYS_LOG_LEVEL_WARNING = 1,
73 GHOSTTY_SYS_LOG_LEVEL_INFO = 2,
74 GHOSTTY_SYS_LOG_LEVEL_DEBUG = 3,
75 GHOSTTY_SYS_LOG_LEVEL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
76} GhosttySysLogLevel;
77
78/**
79 * Callback type for logging.
80 *
81 * When installed, internal library log messages are delivered through
82 * this callback instead of being discarded. The embedder is responsible
83 * for formatting and routing log output.
84 *
85 * @p scope is the log scope name as UTF-8 bytes (e.g. "osc", "kitty").
86 * When the log is unscoped (default scope), @p scope_len is 0.
87 *
88 * All pointer arguments are only valid for the duration of the callback.
89 * The callback must be safe to call from any thread.
90 *
91 * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA
92 * @param level The severity level of the log message
93 * @param scope Pointer to the scope name bytes
94 * @param scope_len Length of the scope name in bytes
95 * @param message Pointer to the log message bytes
96 * @param message_len Length of the log message in bytes
97 */
98typedef void (*GhosttySysLogFn)(
99 void* userdata,
100 GhosttySysLogLevel level,
101 const uint8_t* scope,
102 size_t scope_len,
103 const uint8_t* message,
104 size_t message_len);
105
106/**
107 * Callback type for PNG decoding.
108 *
109 * Decodes raw PNG data into RGBA pixels. The output pixel data must be
110 * allocated through the provided allocator. The library takes ownership
111 * of the buffer and will free it with the same allocator.
112 *
113 * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA
114 * @param allocator The allocator to use for the output pixel buffer
115 * @param data Pointer to the raw PNG data
116 * @param data_len Length of the raw PNG data in bytes
117 * @param[out] out On success, filled with the decoded image
118 * @return true on success, false on failure
119 */
120typedef bool (*GhosttySysDecodePngFn)(
121 void* userdata,
122 const GhosttyAllocator* allocator,
123 const uint8_t* data,
124 size_t data_len,
125 GhosttySysImage* out);
126
127/**
128 * System option identifiers for ghostty_sys_set().
129 */
130typedef enum GHOSTTY_ENUM_TYPED {
131 /**
132 * Set the userdata pointer passed to all sys callbacks.
133 *
134 * Input type: void* (or NULL)
135 */
136 GHOSTTY_SYS_OPT_USERDATA = 0,
137
138 /**
139 * Set the PNG decode function.
140 *
141 * When set, the terminal can accept PNG images via the Kitty
142 * Graphics Protocol. When cleared (NULL value), PNG decoding is
143 * unsupported and PNG image data will be rejected.
144 *
145 * Input type: GhosttySysDecodePngFn (function pointer, or NULL)
146 */
147 GHOSTTY_SYS_OPT_DECODE_PNG = 1,
148
149 /**
150 * Set the log callback.
151 *
152 * When set, internal library log messages are delivered to this
153 * callback. When cleared (NULL value), log messages are silently
154 * discarded.
155 *
156 * Use ghostty_sys_log_stderr as a convenience callback that
157 * writes formatted messages to stderr.
158 *
159 * Which log levels are emitted depends on the build mode of the
160 * library and is not configurable at runtime. Debug builds emit
161 * all levels (debug and above). Release builds emit info and
162 * above; debug-level messages are compiled out entirely and will
163 * never reach the callback.
164 *
165 * Input type: GhosttySysLogFn (function pointer, or NULL)
166 */
167 GHOSTTY_SYS_OPT_LOG = 2,
168 GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
169} GhosttySysOption;
170
171/**
172 * Set a system-level option.
173 *
174 * Configures a process-global implementation function. These should be
175 * set once at startup before using any terminal functionality that
176 * depends on them.
177 *
178 * @param option The option to set
179 * @param value Pointer to the value (type depends on the option),
180 * or NULL to clear it
181 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the
182 * option is not recognized
183 */
184GHOSTTY_API GhosttyResult ghostty_sys_set(GhosttySysOption option,
185 const void* value);
186
187/**
188 * Built-in log callback that writes to stderr.
189 *
190 * Formats each message as "[level](scope): message\n".
191 * Can be passed directly to ghostty_sys_set():
192 *
193 * @code
194 * ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr);
195 * @endcode
196 */
197GHOSTTY_API void ghostty_sys_log_stderr(void* userdata,
198 GhosttySysLogLevel level,
199 const uint8_t* scope,
200 size_t scope_len,
201 const uint8_t* message,
202 size_t message_len);
203
204#ifdef __cplusplus
205}
206#endif
207
208/** @} */
209
210#endif /* GHOSTTY_VT_SYS_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/terminal.h created+1322
...@@ -0,0 +1,1322 @@
1/**
2 * @file terminal.h
3 *
4 * Complete terminal emulator state and rendering.
5 */
6
7#ifndef GHOSTTY_VT_TERMINAL_H
8#define GHOSTTY_VT_TERMINAL_H
9
10#include <stdbool.h>
11#include <stddef.h>
12#include <stdint.h>
13#include <ghostty/vt/types.h>
14#include <ghostty/vt/allocator.h>
15#include <ghostty/vt/device.h>
16#include <ghostty/vt/modes.h>
17#include <ghostty/vt/size_report.h>
18#include <ghostty/vt/grid_ref.h>
19#include <ghostty/vt/kitty_graphics.h>
20#include <ghostty/vt/screen.h>
21#include <ghostty/vt/point.h>
22#include <ghostty/vt/selection.h>
23#include <ghostty/vt/style.h>
24
25#ifdef __cplusplus
26extern "C" {
27#endif
28
29/** @defgroup terminal Terminal
30 *
31 * Complete terminal emulator state and rendering.
32 *
33 * A terminal instance manages the full emulator state including the screen,
34 * scrollback, cursor, styles, modes, and VT stream processing.
35 *
36 * Once a terminal session is up and running, you can configure a key encoder
37 * to write keyboard input via ghostty_key_encoder_setopt_from_terminal().
38 *
39 * ### Example: VT stream processing
40 * @snippet c-vt-stream/src/main.c vt-stream-init
41 * @snippet c-vt-stream/src/main.c vt-stream-write
42 *
43 * ## Effects
44 *
45 * By default, the terminal sequence processing with ghostty_terminal_vt_write()
46 * only process sequences that directly affect terminal state and
47 * ignores sequences that have side effect behavior or require responses.
48 * These sequences include things like bell characters, title changes, device
49 * attributes queries, and more. To handle these sequences, the embedder
50 * must configure "effects."
51 *
52 * Effects are callbacks that the terminal invokes in response to VT
53 * sequences processed during ghostty_terminal_vt_write(). They let the
54 * embedding application react to terminal-initiated events such as bell
55 * characters, title changes, device status report responses, and more.
56 *
57 * Each effect is registered with ghostty_terminal_set() using the
58 * corresponding `GhosttyTerminalOption` identifier. A `NULL` value
59 * pointer clears the callback and disables the effect.
60 *
61 * A userdata pointer can be attached via `GHOSTTY_TERMINAL_OPT_USERDATA`
62 * and is passed to every callback, allowing callers to route events
63 * back to their own application state without global variables.
64 * You cannot specify different userdata for different callbacks.
65 *
66 * All callbacks are invoked synchronously during
67 * ghostty_terminal_vt_write(). Callbacks **must not** call
68 * ghostty_terminal_vt_write() on the same terminal (no reentrancy).
69 * And callbacks must be very careful to not block for too long or perform
70 * expensive operations, since they are blocking further IO processing.
71 *
72 * The available effects are:
73 *
74 * | Option | Callback Type | Trigger |
75 * |-----------------------------------------|-----------------------------------|-------------------------------------------|
76 * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty |
77 * | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) |
78 * | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 |
79 * | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 |
80 * | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) |
81 * | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) |
82 * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) |
83 * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) |
84 * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)|
85 *
86 * ### Defining a write_pty callback
87 * @snippet c-vt-effects/src/main.c effects-write-pty
88 *
89 * ### Defining a bell callback
90 * @snippet c-vt-effects/src/main.c effects-bell
91 *
92 * ### Defining a title_changed callback
93 * @snippet c-vt-effects/src/main.c effects-title-changed
94 *
95 * ### Registering effects and processing VT data
96 * @snippet c-vt-effects/src/main.c effects-register
97 *
98 * ## Color Theme
99 *
100 * The terminal maintains a set of colors used for rendering: a foreground
101 * color, a background color, a cursor color, and a 256-color palette. Each
102 * of these has two layers: a **default** value set by the embedder, and an
103 * **override** value that programs running in the terminal can set via OSC
104 * escape sequences (e.g. OSC 10/11/12 for foreground/background/cursor,
105 * OSC 4 for individual palette entries).
106 *
107 * ### Default Colors
108 *
109 * Use ghostty_terminal_set() with the color options to configure the
110 * default colors. These represent the theme or configuration chosen by
111 * the embedder. Passing `NULL` clears the default, leaving the color
112 * unset.
113 *
114 * | Option | Input Type | Description |
115 * |-----------------------------------------|-------------------------|--------------------------------------|
116 * | `GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND` | `GhosttyColorRgb*` | Default foreground color |
117 * | `GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND` | `GhosttyColorRgb*` | Default background color |
118 * | `GHOSTTY_TERMINAL_OPT_COLOR_CURSOR` | `GhosttyColorRgb*` | Default cursor color |
119 * | `GHOSTTY_TERMINAL_OPT_COLOR_PALETTE` | `GhosttyColorRgb[256]*` | Default 256-color palette |
120 *
121 * For the palette, passing `NULL` resets to the built-in default palette.
122 * The palette set operation preserves any per-index OSC overrides that
123 * programs have applied; only unmodified indices are updated.
124 *
125 * ### Reading colors
126 *
127 * Use ghostty_terminal_get() to read colors. There are two variants for
128 * each color: the **effective** value (which returns the OSC override if
129 * one is active, otherwise the default) and the **default** value (which
130 * ignores any OSC overrides).
131 *
132 * | Data | Output Type | Description |
133 * |---------------------------------------------------|-------------------------|------------------------------------------------|
134 * | `GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND` | `GhosttyColorRgb*` | Effective foreground (override or default) |
135 * | `GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND` | `GhosttyColorRgb*` | Effective background (override or default) |
136 * | `GHOSTTY_TERMINAL_DATA_COLOR_CURSOR` | `GhosttyColorRgb*` | Effective cursor (override or default) |
137 * | `GHOSTTY_TERMINAL_DATA_COLOR_PALETTE` | `GhosttyColorRgb[256]*` | Current palette (with any OSC overrides) |
138 * | `GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT` | `GhosttyColorRgb*` | Default foreground only (ignores OSC override) |
139 * | `GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT` | `GhosttyColorRgb*` | Default background only (ignores OSC override) |
140 * | `GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT` | `GhosttyColorRgb*` | Default cursor only (ignores OSC override) |
141 * | `GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT` | `GhosttyColorRgb[256]*` | Default palette only (ignores OSC overrides) |
142 *
143 * For foreground, background, and cursor colors, the getters return
144 * `GHOSTTY_NO_VALUE` if no color is configured (neither a default nor an
145 * OSC override). The palette getters always succeed since the palette
146 * always has a value (the built-in default if nothing else is set).
147 *
148 * ### Setting a color theme
149 * @snippet c-vt-colors/src/main.c colors-set-defaults
150 *
151 * ### Reading effective and default colors
152 * @snippet c-vt-colors/src/main.c colors-read
153 *
154 * ### Full example with OSC overrides
155 * @snippet c-vt-colors/src/main.c colors-main
156 *
157 * @{
158 */
159
160/**
161 * Terminal initialization options.
162 *
163 * @ingroup terminal
164 */
165typedef struct {
166 /** Terminal width in cells. Must be greater than zero. */
167 uint16_t cols;
168
169 /** Terminal height in cells. Must be greater than zero. */
170 uint16_t rows;
171
172 /** Maximum number of lines to keep in scrollback history. */
173 size_t max_scrollback;
174
175 // TODO: Consider ABI compatibility implications of this struct.
176 // We may want to artificially pad it significantly to support
177 // future options.
178} GhosttyTerminalOptions;
179
180/**
181 * Scroll viewport behavior tag.
182 *
183 * @ingroup terminal
184 */
185typedef enum GHOSTTY_ENUM_TYPED {
186 /** Scroll to the top of the scrollback. */
187 GHOSTTY_SCROLL_VIEWPORT_TOP,
188
189 /** Scroll to the bottom (active area). */
190 GHOSTTY_SCROLL_VIEWPORT_BOTTOM,
191
192 /** Scroll by a delta amount (up is negative). */
193 GHOSTTY_SCROLL_VIEWPORT_DELTA,
194 GHOSTTY_SCROLL_VIEWPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
195} GhosttyTerminalScrollViewportTag;
196
197/**
198 * Scroll viewport value.
199 *
200 * @ingroup terminal
201 */
202typedef union {
203 /** Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative. */
204 intptr_t delta;
205
206 /** Padding for ABI compatibility. Do not use. */
207 uint64_t _padding[2];
208} GhosttyTerminalScrollViewportValue;
209
210/**
211 * Tagged union for scroll viewport behavior.
212 *
213 * @ingroup terminal
214 */
215typedef struct {
216 GhosttyTerminalScrollViewportTag tag;
217 GhosttyTerminalScrollViewportValue value;
218} GhosttyTerminalScrollViewport;
219
220/**
221 * Terminal screen identifier.
222 *
223 * Identifies which screen buffer is active in the terminal.
224 *
225 * @ingroup terminal
226 */
227typedef enum GHOSTTY_ENUM_TYPED {
228 /** The primary (normal) screen. */
229 GHOSTTY_TERMINAL_SCREEN_PRIMARY = 0,
230
231 /** The alternate screen. */
232 GHOSTTY_TERMINAL_SCREEN_ALTERNATE = 1,
233 GHOSTTY_TERMINAL_SCREEN_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
234} GhosttyTerminalScreen;
235
236/**
237 * Visual style of the terminal cursor.
238 *
239 * @ingroup terminal
240 */
241typedef enum GHOSTTY_ENUM_TYPED {
242 /** Bar cursor (DECSCUSR 5, 6). */
243 GHOSTTY_TERMINAL_CURSOR_STYLE_BAR = 0,
244
245 /** Block cursor (DECSCUSR 1, 2). */
246 GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK = 1,
247
248 /** Underline cursor (DECSCUSR 3, 4). */
249 GHOSTTY_TERMINAL_CURSOR_STYLE_UNDERLINE = 2,
250
251 /** Hollow block cursor. */
252 GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK_HOLLOW = 3,
253 GHOSTTY_TERMINAL_CURSOR_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
254} GhosttyTerminalCursorStyle;
255
256/**
257 * Scrollbar state for the terminal viewport.
258 *
259 * Represents the scrollable area dimensions needed to render a scrollbar.
260 *
261 * @ingroup terminal
262 */
263typedef struct {
264 /** Total size of the scrollable area in rows. */
265 uint64_t total;
266
267 /** Offset into the total area that the viewport is at. */
268 uint64_t offset;
269
270 /** Length of the visible area in rows. */
271 uint64_t len;
272} GhosttyTerminalScrollbar;
273
274/**
275 * Callback function type for bell.
276 *
277 * Called when the terminal receives a BEL character (0x07).
278 *
279 * @param terminal The terminal handle
280 * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
281 *
282 * @ingroup terminal
283 */
284typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal,
285 void* userdata);
286
287/**
288 * Callback function type for color scheme queries (CSI ? 996 n).
289 *
290 * Called when the terminal receives a color scheme device status report
291 * query. Return true and fill *out_scheme with the current color scheme,
292 * or return false to silently ignore the query.
293 *
294 * @param terminal The terminal handle
295 * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
296 * @param[out] out_scheme Pointer to store the current color scheme
297 * @return true if the color scheme was filled, false to ignore the query
298 *
299 * @ingroup terminal
300 */
301typedef bool (*GhosttyTerminalColorSchemeFn)(GhosttyTerminal terminal,
302 void* userdata,
303 GhosttyColorScheme* out_scheme);
304
305/**
306 * Callback function type for device attributes queries (DA1/DA2/DA3).
307 *
308 * Called when the terminal receives a device attributes query (CSI c,
309 * CSI > c, or CSI = c). Return true and fill *out_attrs with the
310 * response data, or return false to silently ignore the query.
311 *
312 * The terminal uses whichever sub-struct (primary, secondary, tertiary)
313 * matches the request type, but all three should be filled for simplicity.
314 *
315 * @param terminal The terminal handle
316 * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
317 * @param[out] out_attrs Pointer to store the device attributes response
318 * @return true if attributes were filled, false to ignore the query
319 *
320 * @ingroup terminal
321 */
322typedef bool (*GhosttyTerminalDeviceAttributesFn)(GhosttyTerminal terminal,
323 void* userdata,
324 GhosttyDeviceAttributes* out_attrs);
325
326/**
327 * Callback function type for enquiry (ENQ, 0x05).
328 *
329 * Called when the terminal receives an ENQ character. Return the
330 * response bytes as a GhosttyString. The memory must remain valid
331 * until the callback returns. Return a zero-length string to send
332 * no response.
333 *
334 * @param terminal The terminal handle
335 * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
336 * @return The response bytes to write back to the pty
337 *
338 * @ingroup terminal
339 */
340typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal,
341 void* userdata);
342
343/**
344 * Callback function type for size queries (XTWINOPS).
345 *
346 * Called in response to XTWINOPS size queries (CSI 14/16/18 t).
347 * Return true and fill *out_size with the current terminal geometry,
348 * or return false to silently ignore the query.
349 *
350 * @param terminal The terminal handle
351 * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
352 * @param[out] out_size Pointer to store the terminal size information
353 * @return true if size was filled, false to ignore the query
354 *
355 * @ingroup terminal
356 */
357typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal,
358 void* userdata,
359 GhosttySizeReportSize* out_size);
360
361/**
362 * Callback function type for title_changed.
363 *
364 * Called when the terminal title changes via escape sequences
365 * (e.g. OSC 0 or OSC 2). The new title can be queried from the
366 * terminal after the callback returns.
367 *
368 * @param terminal The terminal handle
369 * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
370 *
371 * @ingroup terminal
372 */
373typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal,
374 void* userdata);
375
376/**
377 * Callback function type for pwd_changed.
378 *
379 * Called when the terminal pwd (current working directory) changes via
380 * escape sequences: OSC 7 (file:// URI), OSC 9 (ConEmu CurrentDir), or
381 * OSC 1337 CurrentDir (iTerm2). Use ghostty_terminal_get() with
382 * GHOSTTY_TERMINAL_DATA_PWD inside the callback to read the new value.
383 *
384 * The terminal stores whatever bytes the shell emitted, without parsing.
385 * That means for OSC 7 the value is the raw URI (typically file://...);
386 * for OSC 9/OSC 1337 it is typically a bare path. The embedder is
387 * responsible for decoding any URI scheme or host if it cares about them.
388 *
389 * The callback also fires when the shell clears the pwd (e.g. an empty
390 * OSC 7). In that case GHOSTTY_TERMINAL_DATA_PWD returns a zero-length
391 * string.
392 *
393 * @param terminal The terminal handle
394 * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
395 *
396 * @ingroup terminal
397 */
398typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal,
399 void* userdata);
400
401/**
402 * Callback function type for write_pty.
403 *
404 * Called when the terminal needs to write data back to the pty, for
405 * example in response to a device status report or mode query. The
406 * data is only valid for the duration of the call; callers must copy
407 * it if it needs to persist.
408 *
409 * @param terminal The terminal handle
410 * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
411 * @param data Pointer to the response bytes
412 * @param len Length of the response in bytes
413 *
414 * @ingroup terminal
415 */
416typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal,
417 void* userdata,
418 const uint8_t* data,
419 size_t len);
420
421/**
422 * Callback function type for XTVERSION.
423 *
424 * Called when the terminal receives an XTVERSION query (CSI > q).
425 * Return the version string (e.g. "myterm 1.0") as a GhosttyString.
426 * The memory must remain valid until the callback returns. Return a
427 * zero-length string to report the default "libghostty" version.
428 *
429 * @param terminal The terminal handle
430 * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
431 * @return The version string to report
432 *
433 * @ingroup terminal
434 */
435typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal,
436 void* userdata);
437
438/**
439 * Terminal option identifiers.
440 *
441 * These values are used with ghostty_terminal_set() to configure
442 * terminal callbacks and associated state.
443 *
444 * @ingroup terminal
445 */
446typedef enum GHOSTTY_ENUM_TYPED {
447 /**
448 * Opaque userdata pointer passed to all callbacks.
449 *
450 * Input type: void*
451 */
452 GHOSTTY_TERMINAL_OPT_USERDATA = 0,
453
454 /**
455 * Callback invoked when the terminal needs to write data back
456 * to the pty (e.g. in response to a DECRQM query or device
457 * status report). Set to NULL to ignore such sequences.
458 *
459 * Input type: GhosttyTerminalWritePtyFn
460 */
461 GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1,
462
463 /**
464 * Callback invoked when the terminal receives a BEL character
465 * (0x07). Set to NULL to ignore bell events.
466 *
467 * Input type: GhosttyTerminalBellFn
468 */
469 GHOSTTY_TERMINAL_OPT_BELL = 2,
470
471 /**
472 * Callback invoked when the terminal receives an ENQ character
473 * (0x05). Set to NULL to send no response.
474 *
475 * Input type: GhosttyTerminalEnquiryFn
476 */
477 GHOSTTY_TERMINAL_OPT_ENQUIRY = 3,
478
479 /**
480 * Callback invoked when the terminal receives an XTVERSION query
481 * (CSI > q). Set to NULL to report the default "libghostty" string.
482 *
483 * Input type: GhosttyTerminalXtversionFn
484 */
485 GHOSTTY_TERMINAL_OPT_XTVERSION = 4,
486
487 /**
488 * Callback invoked when the terminal title changes via escape
489 * sequences (e.g. OSC 0 or OSC 2). Set to NULL to ignore title
490 * change events.
491 *
492 * Input type: GhosttyTerminalTitleChangedFn
493 */
494 GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5,
495
496 /**
497 * Callback invoked in response to XTWINOPS size queries
498 * (CSI 14/16/18 t). Set to NULL to silently ignore size queries.
499 *
500 * Input type: GhosttyTerminalSizeFn
501 */
502 GHOSTTY_TERMINAL_OPT_SIZE = 6,
503
504 /**
505 * Callback invoked in response to a color scheme device status
506 * report query (CSI ? 996 n). Return true and fill the out pointer
507 * to report the current scheme, or return false to silently ignore.
508 * Set to NULL to ignore color scheme queries.
509 *
510 * Input type: GhosttyTerminalColorSchemeFn
511 */
512 GHOSTTY_TERMINAL_OPT_COLOR_SCHEME = 7,
513
514 /**
515 * Callback invoked in response to a device attributes query
516 * (CSI c, CSI > c, or CSI = c). Return true and fill the out
517 * pointer with response data, or return false to silently ignore.
518 * Set to NULL to ignore device attributes queries.
519 *
520 * Input type: GhosttyTerminalDeviceAttributesFn
521 */
522 GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES = 8,
523
524 /**
525 * Set the terminal title manually.
526 *
527 * The string data is copied into the terminal. A NULL value pointer
528 * clears the title (equivalent to setting an empty string).
529 *
530 * Input type: GhosttyString*
531 */
532 GHOSTTY_TERMINAL_OPT_TITLE = 9,
533
534 /**
535 * Set the terminal working directory manually.
536 *
537 * The string data is copied into the terminal. A NULL value pointer
538 * clears the pwd (equivalent to setting an empty string).
539 *
540 * Input type: GhosttyString*
541 */
542 GHOSTTY_TERMINAL_OPT_PWD = 10,
543
544 /**
545 * Set the default foreground color.
546 *
547 * A NULL value pointer clears the default (unset).
548 *
549 * Input type: GhosttyColorRgb*
550 */
551 GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND = 11,
552
553 /**
554 * Set the default background color.
555 *
556 * A NULL value pointer clears the default (unset).
557 *
558 * Input type: GhosttyColorRgb*
559 */
560 GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND = 12,
561
562 /**
563 * Set the default cursor color.
564 *
565 * A NULL value pointer clears the default (unset).
566 *
567 * Input type: GhosttyColorRgb*
568 */
569 GHOSTTY_TERMINAL_OPT_COLOR_CURSOR = 13,
570
571 /**
572 * Set the default 256-color palette.
573 *
574 * The value must point to an array of exactly 256 GhosttyColorRgb values.
575 * A NULL value pointer resets to the built-in default palette.
576 *
577 * Input type: GhosttyColorRgb[256]*
578 */
579 GHOSTTY_TERMINAL_OPT_COLOR_PALETTE = 14,
580
581 /**
582 * Set the Kitty image storage limit in bytes.
583 *
584 * Applied to all initialized screens (primary and alternate).
585 * A value of zero disables the Kitty graphics protocol entirely,
586 * deleting all stored images and placements. A NULL value pointer
587 * is equivalent to zero (disables). Has no effect when Kitty graphics
588 * are disabled at build time.
589 *
590 * Input type: uint64_t*
591 */
592 GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT = 15,
593
594 /**
595 * Enable or disable Kitty image loading via the file medium.
596 *
597 * A NULL value pointer is a no-op. Has no effect when Kitty graphics
598 * are disabled at build time.
599 *
600 * Input type: bool*
601 */
602 GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE = 16,
603
604 /**
605 * Enable or disable Kitty image loading via the temporary file medium.
606 *
607 * A NULL value pointer is a no-op. Has no effect when Kitty graphics
608 * are disabled at build time.
609 *
610 * Input type: bool*
611 */
612 GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE = 17,
613
614 /**
615 * Enable or disable Kitty image loading via the shared memory medium.
616 *
617 * A NULL value pointer is a no-op. Has no effect when Kitty graphics
618 * are disabled at build time.
619 *
620 * Input type: bool*
621 */
622 GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM = 18,
623
624 /**
625 * Set the maximum bytes the APC handler will buffer for all protocols.
626 * This prevents malicious input from causing unbounded memory allocation.
627 * A NULL value pointer removes all overrides, reverting to the built-in
628 * defaults.
629 *
630 * Input type: size_t*
631 */
632 GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES = 19,
633
634 /**
635 * Set the maximum bytes the APC handler will buffer for Kitty graphics
636 * protocol data. A NULL value pointer removes the override, reverting
637 * to the built-in default.
638 *
639 * Input type: size_t*
640 */
641 GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY = 20,
642
643 /**
644 * Set the active screen selection.
645 *
646 * The value must point to a GhosttySelection whose grid references are
647 * valid for this terminal's active screen at the time of the call. The
648 * terminal copies the selection immediately and converts it to
649 * terminal-owned tracked state, so the GhosttySelection struct and its
650 * untracked grid references do not need to outlive this call.
651 *
652 * Passing NULL clears the active screen selection.
653 *
654 * Input type: GhosttySelection*
655 */
656 GHOSTTY_TERMINAL_OPT_SELECTION = 21,
657
658 /**
659 * Set the default cursor style used by DECSCUSR reset (CSI 0 q).
660 *
661 * A NULL value pointer resets to the built-in default block cursor.
662 *
663 * Input type: GhosttyTerminalCursorStyle*
664 */
665 GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_STYLE = 22,
666
667 /**
668 * Set whether the default cursor should blink when reset by DECSCUSR
669 * (CSI 0 q).
670 *
671 * A NULL value pointer resets to the built-in default of not blinking.
672 *
673 * Input type: bool*
674 */
675 GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK = 23,
676
677 /**
678 * Enable or disable Glyph Protocol APC handling.
679 *
680 * When disabled, Glyph Protocol APC sequences are ignored and no
681 * support/query/register/clear responses are emitted. Disabling also clears
682 * the terminal session's glyph glossary. A NULL value pointer is a no-op.
683 *
684 * Input type: bool*
685 */
686 GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL = 24,
687
688 /**
689 * Callback invoked when the terminal pwd changes via escape
690 * sequences (OSC 7, OSC 9, or OSC 1337 CurrentDir). Set to NULL
691 * to ignore pwd change events.
692 *
693 * Input type: GhosttyTerminalPwdChangedFn
694 */
695 GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25,
696 GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
697} GhosttyTerminalOption;
698
699/**
700 * Terminal data types.
701 *
702 * These values specify what type of data to extract from a terminal
703 * using `ghostty_terminal_get`.
704 *
705 * @ingroup terminal
706 */
707typedef enum GHOSTTY_ENUM_TYPED {
708 /** Invalid data type. Never results in any data extraction. */
709 GHOSTTY_TERMINAL_DATA_INVALID = 0,
710
711 /**
712 * Terminal width in cells.
713 *
714 * Output type: uint16_t *
715 */
716 GHOSTTY_TERMINAL_DATA_COLS = 1,
717
718 /**
719 * Terminal height in cells.
720 *
721 * Output type: uint16_t *
722 */
723 GHOSTTY_TERMINAL_DATA_ROWS = 2,
724
725 /**
726 * Cursor column position (0-indexed).
727 *
728 * Output type: uint16_t *
729 */
730 GHOSTTY_TERMINAL_DATA_CURSOR_X = 3,
731
732 /**
733 * Cursor row position within the active area (0-indexed).
734 *
735 * Output type: uint16_t *
736 */
737 GHOSTTY_TERMINAL_DATA_CURSOR_Y = 4,
738
739 /**
740 * Whether the cursor has a pending wrap (next print will soft-wrap).
741 *
742 * Output type: bool *
743 */
744 GHOSTTY_TERMINAL_DATA_CURSOR_PENDING_WRAP = 5,
745
746 /**
747 * The currently active screen.
748 *
749 * Output type: GhosttyTerminalScreen *
750 */
751 GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN = 6,
752
753 /**
754 * Whether the cursor is visible (DEC mode 25).
755 *
756 * Output type: bool *
757 */
758 GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE = 7,
759
760 /**
761 * Current Kitty keyboard protocol flags.
762 *
763 * Output type: GhosttyKittyKeyFlags * (uint8_t *)
764 */
765 GHOSTTY_TERMINAL_DATA_KITTY_KEYBOARD_FLAGS = 8,
766
767 /**
768 * Scrollbar state for the terminal viewport.
769 *
770 * This may be expensive to calculate depending on where the viewport
771 * is (arbitrary pins are expensive). The caller should take care to only
772 * call this as needed and not too frequently.
773 *
774 * Output type: GhosttyTerminalScrollbar *
775 */
776 GHOSTTY_TERMINAL_DATA_SCROLLBAR = 9,
777
778 /**
779 * The current SGR style of the cursor.
780 *
781 * This is the style that will be applied to newly printed characters.
782 *
783 * Output type: GhosttyStyle *
784 */
785 GHOSTTY_TERMINAL_DATA_CURSOR_STYLE = 10,
786
787 /**
788 * Whether any mouse tracking mode is active.
789 *
790 * Returns true if any of the mouse tracking modes (X10, normal, button,
791 * or any-event) are enabled.
792 *
793 * Output type: bool *
794 */
795 GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING = 11,
796
797 /**
798 * The terminal title as set by escape sequences (e.g. OSC 0/2).
799 *
800 * Returns a borrowed string. The pointer is valid until the next call
801 * to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty
802 * string (len=0) is returned when no title has been set.
803 *
804 * Output type: GhosttyString *
805 */
806 GHOSTTY_TERMINAL_DATA_TITLE = 12,
807
808 /**
809 * The terminal's current working directory as set by escape sequences
810 * (e.g. OSC 7).
811 *
812 * Returns a borrowed string. The pointer is valid until the next call
813 * to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty
814 * string (len=0) is returned when no pwd has been set.
815 *
816 * Output type: GhosttyString *
817 */
818 GHOSTTY_TERMINAL_DATA_PWD = 13,
819
820 /**
821 * The total number of rows in the active screen including scrollback.
822 *
823 * Output type: size_t *
824 */
825 GHOSTTY_TERMINAL_DATA_TOTAL_ROWS = 14,
826
827 /**
828 * The number of scrollback rows (total rows minus viewport rows).
829 *
830 * Output type: size_t *
831 */
832 GHOSTTY_TERMINAL_DATA_SCROLLBACK_ROWS = 15,
833
834 /**
835 * The total width of the terminal in pixels.
836 *
837 * This is cols * cell_width_px as set by ghostty_terminal_resize().
838 *
839 * Output type: uint32_t *
840 */
841 GHOSTTY_TERMINAL_DATA_WIDTH_PX = 16,
842
843 /**
844 * The total height of the terminal in pixels.
845 *
846 * This is rows * cell_height_px as set by ghostty_terminal_resize().
847 *
848 * Output type: uint32_t *
849 */
850 GHOSTTY_TERMINAL_DATA_HEIGHT_PX = 17,
851
852 /**
853 * The effective foreground color (override or default).
854 *
855 * Returns GHOSTTY_NO_VALUE if no foreground color is set.
856 *
857 * Output type: GhosttyColorRgb *
858 */
859 GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND = 18,
860
861 /**
862 * The effective background color (override or default).
863 *
864 * Returns GHOSTTY_NO_VALUE if no background color is set.
865 *
866 * Output type: GhosttyColorRgb *
867 */
868 GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND = 19,
869
870 /**
871 * The effective cursor color (override or default).
872 *
873 * Returns GHOSTTY_NO_VALUE if no cursor color is set.
874 *
875 * Output type: GhosttyColorRgb *
876 */
877 GHOSTTY_TERMINAL_DATA_COLOR_CURSOR = 20,
878
879 /**
880 * The current 256-color palette.
881 *
882 * Output type: GhosttyColorRgb[256] *
883 */
884 GHOSTTY_TERMINAL_DATA_COLOR_PALETTE = 21,
885
886 /**
887 * The default foreground color (ignoring any OSC override).
888 *
889 * Returns GHOSTTY_NO_VALUE if no default foreground color is set.
890 *
891 * Output type: GhosttyColorRgb *
892 */
893 GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT = 22,
894
895 /**
896 * The default background color (ignoring any OSC override).
897 *
898 * Returns GHOSTTY_NO_VALUE if no default background color is set.
899 *
900 * Output type: GhosttyColorRgb *
901 */
902 GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT = 23,
903
904 /**
905 * The default cursor color (ignoring any OSC override).
906 *
907 * Returns GHOSTTY_NO_VALUE if no default cursor color is set.
908 *
909 * Output type: GhosttyColorRgb *
910 */
911 GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT = 24,
912
913 /**
914 * The default 256-color palette (ignoring any OSC overrides).
915 *
916 * Output type: GhosttyColorRgb[256] *
917 */
918 GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT = 25,
919
920 /**
921 * The Kitty image storage limit in bytes for the active screen.
922 *
923 * A value of zero means the Kitty graphics protocol is disabled.
924 * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
925 *
926 * Output type: uint64_t *
927 */
928 GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_STORAGE_LIMIT = 26,
929
930 /**
931 * Whether the file medium is enabled for Kitty image loading on the
932 * active screen.
933 *
934 * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
935 *
936 * Output type: bool *
937 */
938 GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE = 27,
939
940 /**
941 * Whether the temporary file medium is enabled for Kitty image loading
942 * on the active screen.
943 *
944 * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
945 *
946 * Output type: bool *
947 */
948 GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE = 28,
949
950 /**
951 * Whether the shared memory medium is enabled for Kitty image loading
952 * on the active screen.
953 *
954 * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
955 *
956 * Output type: bool *
957 */
958 GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM = 29,
959
960 /**
961 * The Kitty graphics image storage for the active screen.
962 *
963 * Returns a borrowed pointer to the image storage. The pointer is valid
964 * until the next mutating terminal call (e.g. ghostty_terminal_vt_write()
965 * or ghostty_terminal_reset()).
966 *
967 * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time.
968 *
969 * Output type: GhosttyKittyGraphics *
970 */
971 GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30,
972
973 /**
974 * The active screen's current selection.
975 *
976 * On success, writes an untracked snapshot of the terminal-owned selection
977 * to the caller-provided GhosttySelection. The GhosttySelection struct is
978 * caller-owned and may be kept, but the grid references inside it are
979 * untracked borrowed references into the active screen. They are only valid
980 * until the next mutating terminal call, such as ghostty_terminal_set(),
981 * ghostty_terminal_vt_write(), ghostty_terminal_resize(), or
982 * ghostty_terminal_reset().
983 *
984 * Returns GHOSTTY_NO_VALUE when there is no active selection.
985 *
986 * Output type: GhosttySelection *
987 */
988 GHOSTTY_TERMINAL_DATA_SELECTION = 31,
989
990 /**
991 * Whether the viewport is currently pinned to the active area.
992 *
993 * This is true when the viewport is following the active terminal area,
994 * and false when the user has scrolled into history.
995 *
996 * Output type: bool *
997 */
998 GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE = 32,
999 GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
1000} GhosttyTerminalData;
1001
1002/**
1003 * Create a new terminal instance.
1004 *
1005 * @param allocator Pointer to allocator, or NULL to use the default allocator
1006 * @param terminal Pointer to store the created terminal handle
1007 * @param options Terminal initialization options
1008 * @return GHOSTTY_SUCCESS on success, or an error code on failure
1009 *
1010 * @ingroup terminal
1011 */
1012GHOSTTY_API GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator,
1013 GhosttyTerminal* terminal,
1014 GhosttyTerminalOptions options);
1015
1016/**
1017 * Free a terminal instance.
1018 *
1019 * Releases all resources associated with the terminal. After this call,
1020 * the terminal handle becomes invalid and must not be used.
1021 *
1022 * @param terminal The terminal handle to free (may be NULL)
1023 *
1024 * @ingroup terminal
1025 */
1026GHOSTTY_API void ghostty_terminal_free(GhosttyTerminal terminal);
1027
1028/**
1029 * Perform a full reset of the terminal (RIS).
1030 *
1031 * Resets all terminal state back to its initial configuration, including
1032 * modes, scrollback, scrolling region, and screen contents. The terminal
1033 * dimensions are preserved.
1034 *
1035 * @param terminal The terminal handle (may be NULL, in which case this is a no-op)
1036 *
1037 * @ingroup terminal
1038 */
1039GHOSTTY_API void ghostty_terminal_reset(GhosttyTerminal terminal);
1040
1041/**
1042 * Resize the terminal to the given dimensions.
1043 *
1044 * Changes the number of columns and rows in the terminal. The primary
1045 * screen will reflow content if wraparound mode is enabled; the alternate
1046 * screen does not reflow. If the dimensions are unchanged, this is a no-op.
1047 *
1048 * This also updates the terminal's pixel dimensions (used for image
1049 * protocols and size reports), disables synchronized output mode (allowed
1050 * by the spec so that resize results are shown immediately), and sends an
1051 * in-band size report if mode 2048 is enabled.
1052 *
1053 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
1054 * @param cols New width in cells (must be greater than zero)
1055 * @param rows New height in cells (must be greater than zero)
1056 * @param cell_width_px Width of a single cell in pixels
1057 * @param cell_height_px Height of a single cell in pixels
1058 * @return GHOSTTY_SUCCESS on success, or an error code on failure
1059 *
1060 * @ingroup terminal
1061 */
1062GHOSTTY_API GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal,
1063 uint16_t cols,
1064 uint16_t rows,
1065 uint32_t cell_width_px,
1066 uint32_t cell_height_px);
1067
1068/**
1069 * Set an option on the terminal.
1070 *
1071 * Configures terminal callbacks and associated state such as the
1072 * write_pty callback and userdata pointer. The value is passed
1073 * directly for pointer types (callbacks, userdata) or as a pointer
1074 * to the value for non-pointer types (e.g. GhosttyString*).
1075 * NULL clears the option to its default.
1076 *
1077 * Callbacks are invoked synchronously during ghostty_terminal_vt_write().
1078 * Callbacks must not call ghostty_terminal_vt_write() on the same
1079 * terminal (no reentrancy).
1080 *
1081 * @param terminal The terminal handle (may be NULL, in which case this is a no-op)
1082 * @param option The option to set
1083 * @param value Pointer to the value to set (type depends on the option),
1084 * or NULL to clear the option
1085 *
1086 * @ingroup terminal
1087 */
1088GHOSTTY_API GhosttyResult ghostty_terminal_set(GhosttyTerminal terminal,
1089 GhosttyTerminalOption option,
1090 const void* value);
1091
1092/**
1093 * Write VT-encoded data to the terminal for processing.
1094 *
1095 * Feeds raw bytes through the terminal's VT stream parser, updating
1096 * terminal state accordingly. By default, sequences that require output
1097 * (queries, device status reports) are silently ignored. Use
1098 * ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_WRITE_PTY to install
1099 * a callback that receives response data.
1100 *
1101 * This never fails. Any erroneous input or errors in processing the
1102 * input are logged internally but do not cause this function to fail
1103 * because this input is assumed to be untrusted and from an external
1104 * source; so the primary goal is to keep the terminal state consistent and
1105 * not allow malformed input to corrupt or crash.
1106 *
1107 * @param terminal The terminal handle
1108 * @param data Pointer to the data to write
1109 * @param len Length of the data in bytes
1110 *
1111 * @ingroup terminal
1112 */
1113GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal,
1114 const uint8_t* data,
1115 size_t len);
1116
1117/**
1118 * Scroll the terminal viewport.
1119 *
1120 * Scrolls the terminal's viewport according to the given behavior.
1121 * When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in
1122 * the value union to specify the number of rows to scroll (negative
1123 * for up, positive for down). For other behaviors, the value is ignored.
1124 *
1125 * @param terminal The terminal handle (may be NULL, in which case this is a no-op)
1126 * @param behavior The scroll behavior as a tagged union
1127 *
1128 * @ingroup terminal
1129 */
1130GHOSTTY_API void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal,
1131 GhosttyTerminalScrollViewport behavior);
1132
1133/**
1134 * Get the current value of a terminal mode.
1135 *
1136 * Returns the value of the mode identified by the given mode.
1137 *
1138 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
1139 * @param mode The mode identifying the mode to query
1140 * @param[out] out_value On success, set to true if the mode is set, false
1141 * if it is reset
1142 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal
1143 * is NULL or the mode does not correspond to a known mode
1144 *
1145 * @ingroup terminal
1146 */
1147GHOSTTY_API GhosttyResult ghostty_terminal_mode_get(GhosttyTerminal terminal,
1148 GhosttyMode mode,
1149 bool* out_value);
1150
1151/**
1152 * Set the value of a terminal mode.
1153 *
1154 * Sets the mode identified by the given mode to the specified value.
1155 *
1156 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
1157 * @param mode The mode identifying the mode to set
1158 * @param value true to set the mode, false to reset it
1159 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal
1160 * is NULL or the mode does not correspond to a known mode
1161 *
1162 * @ingroup terminal
1163 */
1164GHOSTTY_API GhosttyResult ghostty_terminal_mode_set(GhosttyTerminal terminal,
1165 GhosttyMode mode,
1166 bool value);
1167
1168/**
1169 * Get data from a terminal instance.
1170 *
1171 * Extracts typed data from the given terminal based on the specified
1172 * data type. The output pointer must be of the appropriate type for the
1173 * requested data kind. Valid data types and output types are documented
1174 * in the `GhosttyTerminalData` enum.
1175 *
1176 * @param terminal The terminal handle (may be NULL)
1177 * @param data The type of data to extract
1178 * @param out Pointer to store the extracted data (type depends on data parameter)
1179 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal
1180 * is NULL or the data type is invalid
1181 *
1182 * @ingroup terminal
1183 */
1184GHOSTTY_API GhosttyResult ghostty_terminal_get(GhosttyTerminal terminal,
1185 GhosttyTerminalData data,
1186 void *out);
1187
1188/**
1189 * Get multiple data fields from a terminal in a single call.
1190 *
1191 * This is an optimization over calling ghostty_terminal_get()
1192 * repeatedly, particularly useful in environments with high per-call
1193 * overhead such as FFI or Cgo.
1194 *
1195 * Each element in the keys array specifies a data kind, and the
1196 * corresponding element in the values array receives the result.
1197 * The type of each values[i] pointer must match the output type
1198 * documented for keys[i].
1199 *
1200 * Processing stops at the first error; on success out_written
1201 * is set to count, on error it is set to the index of the
1202 * failing key (i.e. the number of values successfully written).
1203 *
1204 * @param terminal The terminal handle (may be NULL)
1205 * @param count Number of key/value pairs
1206 * @param keys Array of data kinds to query
1207 * @param values Array of output pointers (types must match each key's
1208 * documented output type)
1209 * @param[out] out_written On return, receives the number of values
1210 * successfully written (may be NULL)
1211 * @return GHOSTTY_SUCCESS if all queries succeed
1212 *
1213 * @ingroup terminal
1214 */
1215GHOSTTY_API GhosttyResult ghostty_terminal_get_multi(GhosttyTerminal terminal,
1216 size_t count,
1217 const GhosttyTerminalData* keys,
1218 void** values,
1219 size_t* out_written);
1220
1221/**
1222 * Resolve a point in the terminal grid to a grid reference.
1223 *
1224 * Resolves the given point (which can be in active, viewport, screen,
1225 * or history coordinates) to a grid reference for that location. Use
1226 * ghostty_grid_ref_cell() and ghostty_grid_ref_row() to extract the cell
1227 * and row.
1228 *
1229 * Lookups using the `active` and `viewport` tags are fast. The `screen`
1230 * and `history` tags may require traversing the full scrollback page list
1231 * to resolve the y coordinate, so they can be expensive for large
1232 * scrollback buffers.
1233 *
1234 * This function isn't meant to be used as the core of render loop. It
1235 * isn't built to sustain the framerates needed for rendering large screens.
1236 * Use the render state API for that. This API is instead meant for less
1237 * strictly performance-sensitive use cases.
1238 *
1239 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
1240 * @param point The point specifying which cell to look up
1241 * @param[out] out_ref On success, set to the grid reference at the given point (may be NULL)
1242 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal
1243 * is NULL or the point is out of bounds
1244 *
1245 * @ingroup terminal
1246 */
1247GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal,
1248 GhosttyPoint point,
1249 GhosttyGridRef *out_ref);
1250
1251/**
1252 * Create an owned tracked grid reference for a terminal point.
1253 *
1254 * This is the tracked variant of ghostty_terminal_grid_ref(). The returned
1255 * handle follows the referenced cell as the terminal's page list is modified:
1256 * scrolling, pruning, resize/reflow, and other page-list operations update the
1257 * tracked reference automatically.
1258 *
1259 * The reference is attached to the terminal screen/page-list that is active at
1260 * creation time.
1261 *
1262 * If the point is outside the requested coordinate space, this returns
1263 * GHOSTTY_INVALID_VALUE and writes NULL to out_ref.
1264 *
1265 * The returned handle must be freed with ghostty_tracked_grid_ref_free(). If
1266 * the terminal is freed first, the handle remains valid only for
1267 * tracked-grid-ref APIs: it reports no value and can still be freed.
1268 *
1269 * @param terminal Terminal instance.
1270 * @param point Point to track.
1271 * @param[out] out_ref On success, receives the tracked reference handle.
1272 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if terminal,
1273 * point, or out_ref is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation
1274 * fails.
1275 *
1276 * @ingroup terminal
1277 */
1278GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref_track(
1279 GhosttyTerminal terminal,
1280 GhosttyPoint point,
1281 GhosttyTrackedGridRef *out_ref);
1282
1283/**
1284 * Convert a grid reference back to a point in the given coordinate system.
1285 *
1286 * This is the inverse of ghostty_terminal_grid_ref(): given a grid reference,
1287 * it returns the x/y coordinates in the requested coordinate system (active,
1288 * viewport, screen, or history).
1289 *
1290 * The grid reference must have been obtained from the same terminal instance.
1291 * Like all grid references, it is only valid until the next mutating terminal
1292 * call.
1293 *
1294 * Not every grid reference is representable in every coordinate system. For
1295 * example, a cell in scrollback history cannot be expressed in active
1296 * coordinates, and a cell that has scrolled off the visible area cannot be
1297 * expressed in viewport coordinates. In these cases, the function returns
1298 * GHOSTTY_NO_VALUE.
1299 *
1300 * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
1301 * @param ref Pointer to the grid reference to convert
1302 * @param tag The target coordinate system
1303 * @param[out] out On success, set to the coordinate in the requested system (may be NULL)
1304 * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal
1305 * or ref is NULL/invalid, GHOSTTY_NO_VALUE if the ref falls outside
1306 * the requested coordinate system
1307 *
1308 * @ingroup terminal
1309 */
1310GHOSTTY_API GhosttyResult ghostty_terminal_point_from_grid_ref(
1311 GhosttyTerminal terminal,
1312 const GhosttyGridRef *ref,
1313 GhosttyPointTag tag,
1314 GhosttyPointCoordinate *out);
1315
1316/** @} */
1317
1318#ifdef __cplusplus
1319}
1320#endif
1321
1322#endif /* GHOSTTY_VT_TERMINAL_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/types.h created+335
...@@ -0,0 +1,335 @@
1/**
2 * @file types.h
3 *
4 * Common types, macros, and utilities for libghostty-vt.
5 */
6
7#ifndef GHOSTTY_VT_TYPES_H
8#define GHOSTTY_VT_TYPES_H
9
10#include <limits.h>
11#include <stddef.h>
12#include <stdint.h>
13
14// Symbol visibility for shared library builds. On Windows, functions
15// are exported from the DLL when building and imported when consuming.
16// On other platforms with GCC/Clang, functions are marked with default
17// visibility so they remain accessible when the library is built with
18// -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC
19// before including this header to make this a no-op.
20#ifndef GHOSTTY_API
21#if defined(GHOSTTY_STATIC)
22 #define GHOSTTY_API
23#elif defined(_WIN32) || defined(_WIN64)
24 #ifdef GHOSTTY_BUILD_SHARED
25 #define GHOSTTY_API __declspec(dllexport)
26 #else
27 #define GHOSTTY_API __declspec(dllimport)
28 #endif
29#elif defined(__GNUC__) && __GNUC__ >= 4
30 #define GHOSTTY_API __attribute__((visibility("default")))
31#else
32 #define GHOSTTY_API
33#endif
34#endif
35
36/**
37 * Enum int-sizing helpers.
38 *
39 * The Zig side backs all C enums with c_int, so the C declarations
40 * must use int as their underlying type to maintain ABI compatibility.
41 *
42 * C23 (detected via __STDC_VERSION__ >= 202311L) supports explicit
43 * enum underlying types with `enum : int { ... }`. For pre-C23
44 * compilers, which are free to choose any type that can represent
45 * all values (C11 §6.7.2.2), we add an INT_MAX sentinel as the last
46 * entry to force the compiler to use int.
47 *
48 * INT_MAX is used rather than a fixed constant like 0xFFFFFFFF
49 * because enum constants must have type int (which is signed).
50 * Values above INT_MAX overflow signed int and are a constraint
51 * violation in standard C; compilers that accept them interpret them
52 * as negative values via two's complement, which can collide with
53 * legitimate negative enum values.
54 *
55 * Usage:
56 * @code
57 * typedef enum GHOSTTY_ENUM_TYPED {
58 * FOO_A = 0,
59 * FOO_B = 1,
60 * FOO_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
61 * } Foo;
62 * @endcode
63 */
64#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L
65#define GHOSTTY_ENUM_TYPED : int
66#else
67#define GHOSTTY_ENUM_TYPED
68#endif
69#define GHOSTTY_ENUM_MAX_VALUE INT_MAX
70
71/**
72 * Result codes for libghostty-vt operations.
73 */
74typedef enum GHOSTTY_ENUM_TYPED {
75 /** Operation completed successfully */
76 GHOSTTY_SUCCESS = 0,
77 /** Operation failed due to failed allocation */
78 GHOSTTY_OUT_OF_MEMORY = -1,
79 /** Operation failed due to invalid value */
80 GHOSTTY_INVALID_VALUE = -2,
81 /** Operation failed because the provided buffer was too small */
82 GHOSTTY_OUT_OF_SPACE = -3,
83 /** The requested value has no value */
84 GHOSTTY_NO_VALUE = -4,
85 GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
86} GhosttyResult;
87
88/* ---- Opaque handles ---- */
89
90/**
91 * Opaque handle to a terminal instance.
92 *
93 * @ingroup terminal
94 */
95typedef struct GhosttyTerminalImpl* GhosttyTerminal;
96
97/**
98 * Opaque handle to a tracked grid reference.
99 *
100 * A tracked grid reference is owned by the caller and must be freed with
101 * ghostty_tracked_grid_ref_free(). If the terminal that created it is freed
102 * first, the handle remains valid only for tracked-grid-ref APIs: it reports no
103 * value and can still be freed.
104 *
105 * @ingroup grid_ref
106 */
107typedef struct GhosttyTrackedGridRefImpl* GhosttyTrackedGridRef;
108
109/**
110 * Opaque handle to a Kitty graphics image storage.
111 *
112 * Obtained via ghostty_terminal_get() with
113 * GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. The pointer is borrowed from
114 * the terminal and remains valid until the next mutating terminal call
115 * (e.g. ghostty_terminal_vt_write() or ghostty_terminal_reset()).
116 *
117 * @ingroup kitty_graphics
118 */
119typedef struct GhosttyKittyGraphicsImpl* GhosttyKittyGraphics;
120
121/**
122 * Opaque handle to a Kitty graphics image.
123 *
124 * Obtained via ghostty_kitty_graphics_image() with an image ID. The
125 * pointer is borrowed from the storage and remains valid until the next
126 * mutating terminal call.
127 *
128 * @ingroup kitty_graphics
129 */
130typedef const struct GhosttyKittyGraphicsImageImpl* GhosttyKittyGraphicsImage;
131
132/**
133 * Opaque handle to a Kitty graphics placement iterator.
134 *
135 * @ingroup kitty_graphics
136 */
137typedef struct GhosttyKittyGraphicsPlacementIteratorImpl* GhosttyKittyGraphicsPlacementIterator;
138
139/**
140 * Opaque handle to a render state instance.
141 *
142 * @ingroup render
143 */
144typedef struct GhosttyRenderStateImpl* GhosttyRenderState;
145
146/**
147 * Opaque handle to a render-state row iterator.
148 *
149 * @ingroup render
150 */
151typedef struct GhosttyRenderStateRowIteratorImpl* GhosttyRenderStateRowIterator;
152
153/**
154 * Opaque handle to render-state row cells.
155 *
156 * @ingroup render
157 */
158typedef struct GhosttyRenderStateRowCellsImpl* GhosttyRenderStateRowCells;
159
160/**
161 * Opaque handle to an SGR parser instance.
162 *
163 * This handle represents an SGR (Select Graphic Rendition) parser that can
164 * be used to parse SGR sequences and extract individual text attributes.
165 *
166 * @ingroup sgr
167 */
168typedef struct GhosttySgrParserImpl* GhosttySgrParser;
169
170/**
171 * Opaque handle to a formatter instance.
172 *
173 * @ingroup formatter
174 */
175typedef struct GhosttyFormatterImpl* GhosttyFormatter;
176
177/**
178 * Opaque handle to an OSC parser instance.
179 *
180 * This handle represents an OSC (Operating System Command) parser that can
181 * be used to parse the contents of OSC sequences.
182 *
183 * @ingroup osc
184 */
185typedef struct GhosttyOscParserImpl* GhosttyOscParser;
186
187/**
188 * Opaque handle to a single OSC command.
189 *
190 * This handle represents a parsed OSC (Operating System Command) command.
191 * The command can be queried for its type and associated data.
192 *
193 * @ingroup osc
194 */
195typedef struct GhosttyOscCommandImpl* GhosttyOscCommand;
196
197/* ---- Common value types ---- */
198
199/**
200 * Terminal content output format.
201 *
202 * @ingroup formatter
203 */
204typedef enum GHOSTTY_ENUM_TYPED {
205 /** Plain text (no escape sequences). */
206 GHOSTTY_FORMATTER_FORMAT_PLAIN,
207
208 /** VT sequences preserving colors, styles, URLs, etc. */
209 GHOSTTY_FORMATTER_FORMAT_VT,
210
211 /** HTML with inline styles. */
212 GHOSTTY_FORMATTER_FORMAT_HTML,
213 GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
214} GhosttyFormatterFormat;
215
216/**
217 * A borrowed byte string (pointer + length).
218 *
219 * The memory is not owned by this struct. The pointer is only valid
220 * for the lifetime documented by the API that produces or consumes it.
221 */
222typedef struct {
223 /** Pointer to the string bytes. */
224 const uint8_t* ptr;
225
226 /** Length of the string in bytes. */
227 size_t len;
228} GhosttyString;
229
230/**
231 * A caller-provided byte buffer.
232 *
233 * APIs that write to this type use `len` for the number of bytes written on
234 * GHOSTTY_SUCCESS and the required byte capacity on GHOSTTY_OUT_OF_SPACE.
235 */
236typedef struct {
237 /** Destination buffer for bytes. May be NULL when cap is 0 to query required size. */
238 uint8_t* ptr;
239
240 /** Capacity of ptr in bytes. */
241 size_t cap;
242
243 /** Bytes written on success, or required byte capacity on GHOSTTY_OUT_OF_SPACE. */
244 size_t len;
245} GhosttyBuffer;
246
247/**
248 * A surface-space position in pixels.
249 *
250 * This is not a terminal grid coordinate. It represents an x/y position in the
251 * rendered surface coordinate space, with (0, 0) at the top-left of the
252 * surface.
253 */
254typedef struct {
255 /** X position in surface pixels. */
256 double x;
257
258 /** Y position in surface pixels. */
259 double y;
260} GhosttySurfacePosition;
261
262/**
263 * A borrowed list of Unicode scalar values.
264 *
265 * Values are encoded as uint32_t scalar values. The memory is not owned by this
266 * struct. The pointer is only valid for the lifetime documented by the API that
267 * consumes or produces it.
268 *
269 * APIs may document special handling for NULL + len 0, such as “use defaults”.
270 */
271typedef struct {
272 /** Pointer to Unicode scalar values. */
273 const uint32_t* ptr;
274
275 /** Number of entries in ptr. */
276 size_t len;
277} GhosttyCodepoints;
278
279/**
280 * Initialize a sized struct to zero and set its size field.
281 *
282 * Sized structs use a `size` field as the first member for ABI
283 * compatibility. This macro zero-initializes the struct and sets the
284 * size field to `sizeof(type)`, which allows the library to detect
285 * which version of the struct the caller was compiled against.
286 *
287 * @param type The struct type to initialize
288 * @return A zero-initialized struct with the size field set
289 *
290 * Example:
291 * @code
292 * GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
293 * opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
294 * opts.trim = true;
295 * @endcode
296 */
297#define GHOSTTY_INIT_SIZED(type) \
298 ((type){ .size = sizeof(type) })
299
300/**
301 * Return a pointer to a null-terminated JSON string describing the
302 * layout of every C API struct for the current target.
303 *
304 * This is primarily useful for language bindings that can't easily
305 * set C struct fields and need to do so via byte offsets. For example,
306 * WebAssembly modules can't share struct definitions with the host.
307 *
308 * Example (abbreviated):
309 * @code{.json}
310 * {
311 * "GhosttyMouseEncoderSize": {
312 * "size": 40,
313 * "align": 8,
314 * "fields": {
315 * "size": { "offset": 0, "size": 8, "type": "u64" },
316 * "screen_width": { "offset": 8, "size": 4, "type": "u32" },
317 * "screen_height": { "offset": 12, "size": 4, "type": "u32" },
318 * "cell_width": { "offset": 16, "size": 4, "type": "u32" },
319 * "cell_height": { "offset": 20, "size": 4, "type": "u32" },
320 * "padding_top": { "offset": 24, "size": 4, "type": "u32" },
321 * "padding_bottom": { "offset": 28, "size": 4, "type": "u32" },
322 * "padding_right": { "offset": 32, "size": 4, "type": "u32" },
323 * "padding_left": { "offset": 36, "size": 4, "type": "u32" }
324 * }
325 * }
326 * }
327 * @endcode
328 *
329 * The returned pointer is valid for the lifetime of the process.
330 *
331 * @return Pointer to the null-terminated JSON string.
332 */
333GHOSTTY_API const char *ghostty_type_json(void);
334
335#endif /* GHOSTTY_VT_TYPES_H */
apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/wasm.h created+160
...@@ -0,0 +1,160 @@
1/**
2 * @file wasm.h
3 *
4 * WebAssembly utility functions for libghostty-vt.
5 */
6
7#ifndef GHOSTTY_VT_WASM_H
8#define GHOSTTY_VT_WASM_H
9
10#ifdef __wasm__
11
12#include <stddef.h>
13#include <stdint.h>
14#include <ghostty/vt/types.h>
15
16/** @defgroup wasm WebAssembly Utilities
17 *
18 * Convenience functions for allocating various types in WebAssembly builds.
19 * **These are only available the libghostty-vt wasm module.**
20 *
21 * Ghostty relies on pointers to various types for ABI compatibility, and
22 * creating those pointers in Wasm can be tedious. These functions provide
23 * a purely additive set of utilities that simplify memory management in
24 * Wasm environments without changing the core C library API.
25 *
26 * @note These functions always use the default allocator. If you need
27 * custom allocation strategies, you should allocate types manually using
28 * your custom allocator. This is a very rare use case in the WebAssembly
29 * world so these are optimized for simplicity.
30 *
31 * ## Example Usage
32 *
33 * Here's a simple example of using the Wasm utilities with the key encoder:
34 *
35 * @code
36 * const { exports } = wasmInstance;
37 * const view = new DataView(wasmMemory.buffer);
38 *
39 * // Create key encoder
40 * const encoderPtr = exports.ghostty_wasm_alloc_opaque();
41 * exports.ghostty_key_encoder_new(null, encoderPtr);
42 * const encoder = view.getUint32(encoder, true);
43 *
44 * // Configure encoder with Kitty protocol flags
45 * const flagsPtr = exports.ghostty_wasm_alloc_u8();
46 * view.setUint8(flagsPtr, 0x1F);
47 * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr);
48 *
49 * // Allocate output buffer and size pointer
50 * const bufferSize = 32;
51 * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize);
52 * const writtenPtr = exports.ghostty_wasm_alloc_usize();
53 *
54 * // Encode the key event
55 * exports.ghostty_key_encoder_encode(
56 * encoder, eventPtr, bufPtr, bufferSize, writtenPtr
57 * );
58 *
59 * // Read encoded output
60 * const bytesWritten = view.getUint32(writtenPtr, true);
61 * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten);
62 * @endcode
63 *
64 * @remark The code above is pretty ugly! This is the lowest level interface
65 * to the libghostty-vt Wasm module. In practice, this should be wrapped
66 * in a higher-level API that abstracts away all this.
67 *
68 * @{
69 */
70
71/**
72 * Allocate an opaque pointer. This can be used for any opaque pointer
73 * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc.
74 *
75 * @return Pointer to allocated opaque pointer, or NULL if allocation failed
76 * @ingroup wasm
77 */
78GHOSTTY_API void** ghostty_wasm_alloc_opaque(void);
79
80/**
81 * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque().
82 *
83 * @param ptr Pointer to free, or NULL (NULL is safely ignored)
84 * @ingroup wasm
85 */
86GHOSTTY_API void ghostty_wasm_free_opaque(void **ptr);
87
88/**
89 * Allocate an array of uint8_t values.
90 *
91 * @param len Number of uint8_t elements to allocate
92 * @return Pointer to allocated array, or NULL if allocation failed
93 * @ingroup wasm
94 */
95GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8_array(size_t len);
96
97/**
98 * Free an array allocated by ghostty_wasm_alloc_u8_array().
99 *
100 * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
101 * @param len Length of the array (must match the length passed to alloc)
102 * @ingroup wasm
103 */
104GHOSTTY_API void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len);
105
106/**
107 * Allocate an array of uint16_t values.
108 *
109 * @param len Number of uint16_t elements to allocate
110 * @return Pointer to allocated array, or NULL if allocation failed
111 * @ingroup wasm
112 */
113GHOSTTY_API uint16_t* ghostty_wasm_alloc_u16_array(size_t len);
114
115/**
116 * Free an array allocated by ghostty_wasm_alloc_u16_array().
117 *
118 * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
119 * @param len Length of the array (must match the length passed to alloc)
120 * @ingroup wasm
121 */
122GHOSTTY_API void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len);
123
124/**
125 * Allocate a single uint8_t value.
126 *
127 * @return Pointer to allocated uint8_t, or NULL if allocation failed
128 * @ingroup wasm
129 */
130GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8(void);
131
132/**
133 * Free a uint8_t allocated by ghostty_wasm_alloc_u8().
134 *
135 * @param ptr Pointer to free, or NULL (NULL is safely ignored)
136 * @ingroup wasm
137 */
138GHOSTTY_API void ghostty_wasm_free_u8(uint8_t *ptr);
139
140/**
141 * Allocate a single size_t value.
142 *
143 * @return Pointer to allocated size_t, or NULL if allocation failed
144 * @ingroup wasm
145 */
146GHOSTTY_API size_t* ghostty_wasm_alloc_usize(void);
147
148/**
149 * Free a size_t allocated by ghostty_wasm_alloc_usize().
150 *
151 * @param ptr Pointer to free, or NULL (NULL is safely ignored)
152 * @ingroup wasm
153 */
154GHOSTTY_API void ghostty_wasm_free_usize(size_t *ptr);
155
156/** @} */
157
158#endif /* __wasm__ */
159
160#endif /* GHOSTTY_VT_WASM_H */
apps/mobile/modules/t3-terminal/android/.gitignore created+1
...@@ -0,0 +1 @@
1.cxx/
apps/mobile/modules/t3-terminal/android/build.gradle+14
...@@ -6,11 +6,25 @@ version = '0.0.0'...@@ -6,11 +6,25 @@ version = '0.0.0'
66
7android {7android {
8 namespace 'expo.modules.t3terminal'8 namespace 'expo.modules.t3terminal'
9 ndkVersion rootProject.ext.ndkVersion
9 compileSdk rootProject.ext.compileSdkVersion10 compileSdk rootProject.ext.compileSdkVersion
1011
11 defaultConfig {12 defaultConfig {
12 minSdkVersion rootProject.ext.minSdkVersion13 minSdkVersion rootProject.ext.minSdkVersion
13 targetSdkVersion rootProject.ext.targetSdkVersion14 targetSdkVersion rootProject.ext.targetSdkVersion
15
16 externalNativeBuild {
17 cmake {
18 cppFlags '-std=c++17 -Wall -Wextra -Werror'
19 }
20 }
21 }
22
23 externalNativeBuild {
24 cmake {
25 path 'src/main/cpp/CMakeLists.txt'
26 version '3.22.1'
27 }
14 }28 }
15}29}
1630
apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf created
Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf differ
apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf created
Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf differ
apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt created+23
...@@ -0,0 +1,23 @@
1cmake_minimum_required(VERSION 3.22.1)
2
3project(t3terminal LANGUAGES CXX)
4
5add_library(ghostty-vt SHARED IMPORTED)
6set_target_properties(
7 ghostty-vt
8 PROPERTIES IMPORTED_LOCATION
9 "${CMAKE_CURRENT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libghostty-vt.so"
10)
11
12add_library(t3terminal SHARED t3_terminal_jni.cpp)
13
14target_include_directories(
15 t3terminal
16 PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../../../Vendor/libghostty-vt/include"
17)
18
19target_link_options(
20 t3terminal PRIVATE "-Wl,-z,common-page-size=16384" "-Wl,-z,max-page-size=16384"
21)
22
23target_link_libraries(t3terminal PRIVATE ghostty-vt log)
apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp created+516
...@@ -0,0 +1,516 @@
1#include <jni.h>
2
3#include <algorithm>
4#include <cstdint>
5#include <mutex>
6#include <utility>
7#include <vector>
8
9#include <ghostty/vt.h>
10
11namespace {
12
13constexpr uint32_t kSnapshotMagic = 0x54563354; // "T3VT" in little endian.
14constexpr uint16_t kSnapshotVersion = 1;
15constexpr size_t kMaxScrollbackRows = 10000;
16
17enum CellFlag : uint16_t {
18 kBold = 1 << 0,
19 kItalic = 1 << 1,
20 kFaint = 1 << 2,
21 kInverse = 1 << 3,
22 kInvisible = 1 << 4,
23 kStrikethrough = 1 << 5,
24 kOverline = 1 << 6,
25 kUnderline = 1 << 7,
26 kSelected = 1 << 8,
27};
28
29struct Session {
30 GhosttyTerminal terminal = nullptr;
31 GhosttyRenderState render_state = nullptr;
32 GhosttyRenderStateRowIterator row_iterator = nullptr;
33 GhosttyRenderStateRowCells row_cells = nullptr;
34 std::vector<uint8_t> responses;
35 std::mutex mutex;
36};
37
38class ByteWriter {
39 public:
40 explicit ByteWriter(size_t capacity) { bytes_.reserve(capacity); }
41
42 void U8(uint8_t value) { bytes_.push_back(value); }
43
44 void U16(uint16_t value) {
45 U8(static_cast<uint8_t>(value));
46 U8(static_cast<uint8_t>(value >> 8));
47 }
48
49 void U32(uint32_t value) {
50 U16(static_cast<uint16_t>(value));
51 U16(static_cast<uint16_t>(value >> 16));
52 }
53
54 void Bytes(const std::vector<uint8_t>& value) {
55 bytes_.insert(bytes_.end(), value.begin(), value.end());
56 }
57
58 std::vector<uint8_t> Take() { return std::move(bytes_); }
59
60 private:
61 std::vector<uint8_t> bytes_;
62};
63
64Session* FromHandle(jlong handle) {
65 return reinterpret_cast<Session*>(static_cast<intptr_t>(handle));
66}
67
68jbyteArray ToJavaBytes(JNIEnv* env, const std::vector<uint8_t>& bytes) {
69 auto result = env->NewByteArray(static_cast<jsize>(bytes.size()));
70 if (result != nullptr && !bytes.empty()) {
71 env->SetByteArrayRegion(result, 0, static_cast<jsize>(bytes.size()),
72 reinterpret_cast<const jbyte*>(bytes.data()));
73 }
74 return result;
75}
76
77GhosttyColorRgb RgbFromArgb(jint color) {
78 const auto value = static_cast<uint32_t>(color);
79 return {
80 .r = static_cast<uint8_t>(value >> 16),
81 .g = static_cast<uint8_t>(value >> 8),
82 .b = static_cast<uint8_t>(value),
83 };
84}
85
86uint32_t ArgbFromRgb(GhosttyColorRgb color) {
87 return 0xFF000000U | (static_cast<uint32_t>(color.r) << 16U) |
88 (static_cast<uint32_t>(color.g) << 8U) | color.b;
89}
90
91GhosttyColorRgb Blend(GhosttyColorRgb foreground, GhosttyColorRgb background,
92 uint8_t foreground_weight) {
93 const auto blend = [foreground_weight](uint8_t front, uint8_t back) {
94 const uint16_t back_weight = 255 - foreground_weight;
95 return static_cast<uint8_t>((front * foreground_weight + back * back_weight) / 255);
96 };
97 return {
98 .r = blend(foreground.r, background.r),
99 .g = blend(foreground.g, background.g),
100 .b = blend(foreground.b, background.b),
101 };
102}
103
104void AppendUtf8(std::vector<uint8_t>* output, uint32_t codepoint) {
105 if (codepoint <= 0x7F) {
106 output->push_back(static_cast<uint8_t>(codepoint));
107 } else if (codepoint <= 0x7FF) {
108 output->push_back(static_cast<uint8_t>(0xC0 | (codepoint >> 6)));
109 output->push_back(static_cast<uint8_t>(0x80 | (codepoint & 0x3F)));
110 } else if (codepoint <= 0xFFFF && !(codepoint >= 0xD800 && codepoint <= 0xDFFF)) {
111 output->push_back(static_cast<uint8_t>(0xE0 | (codepoint >> 12)));
112 output->push_back(static_cast<uint8_t>(0x80 | ((codepoint >> 6) & 0x3F)));
113 output->push_back(static_cast<uint8_t>(0x80 | (codepoint & 0x3F)));
114 } else if (codepoint <= 0x10FFFF) {
115 output->push_back(static_cast<uint8_t>(0xF0 | (codepoint >> 18)));
116 output->push_back(static_cast<uint8_t>(0x80 | ((codepoint >> 12) & 0x3F)));
117 output->push_back(static_cast<uint8_t>(0x80 | ((codepoint >> 6) & 0x3F)));
118 output->push_back(static_cast<uint8_t>(0x80 | (codepoint & 0x3F)));
119 }
120}
121
122void OnWritePty(GhosttyTerminal, void* userdata, const uint8_t* data, size_t len) {
123 auto* session = static_cast<Session*>(userdata);
124 if (session == nullptr || data == nullptr || len == 0) return;
125 session->responses.insert(session->responses.end(), data, data + len);
126}
127
128void ApplyTheme(Session* session, jint foreground, jint background, jint cursor,
129 JNIEnv* env, jintArray palette_array) {
130 auto foreground_rgb = RgbFromArgb(foreground);
131 auto background_rgb = RgbFromArgb(background);
132 auto cursor_rgb = RgbFromArgb(cursor);
133 ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND,
134 &foreground_rgb);
135 ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND,
136 &background_rgb);
137 ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_CURSOR, &cursor_rgb);
138
139 if (palette_array == nullptr) return;
140 const auto palette_length = env->GetArrayLength(palette_array);
141 if (palette_length <= 0) return;
142
143 GhosttyColorRgb palette[256];
144 if (ghostty_terminal_get(session->terminal,
145 GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT,
146 palette) != GHOSTTY_SUCCESS) {
147 return;
148 }
149
150 const auto copied_length = std::min<jsize>(palette_length, 256);
151 std::vector<jint> colors(static_cast<size_t>(copied_length));
152 env->GetIntArrayRegion(palette_array, 0, copied_length, colors.data());
153 for (jsize index = 0; index < copied_length; ++index) {
154 palette[index] = RgbFromArgb(colors[index]);
155 }
156 ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_PALETTE, palette);
157}
158
159void FreeSession(Session* session) {
160 if (session == nullptr) return;
161 ghostty_render_state_row_cells_free(session->row_cells);
162 ghostty_render_state_row_iterator_free(session->row_iterator);
163 ghostty_render_state_free(session->render_state);
164 ghostty_terminal_free(session->terminal);
165 delete session;
166}
167
168std::vector<uint8_t> DrainResponses(Session* session) {
169 std::vector<uint8_t> responses;
170 responses.swap(session->responses);
171 return responses;
172}
173
174bool ViewportGridRef(Session* session, jint x, jint y, GhosttyGridRef* out) {
175 *out = GhosttyGridRef{};
176 out->size = sizeof(*out);
177 GhosttyPoint point{};
178 point.tag = GHOSTTY_POINT_TAG_VIEWPORT;
179 point.value.coordinate.x = static_cast<uint16_t>(std::max<jint>(x, 0));
180 point.value.coordinate.y = static_cast<uint32_t>(std::max<jint>(y, 0));
181 return ghostty_terminal_grid_ref(session->terminal, point, out) == GHOSTTY_SUCCESS;
182}
183
184uint16_t StyleFlags(const GhosttyStyle& style, bool selected) {
185 uint16_t flags = 0;
186 if (style.bold) flags |= kBold;
187 if (style.italic) flags |= kItalic;
188 if (style.faint) flags |= kFaint;
189 if (style.inverse) flags |= kInverse;
190 if (style.invisible) flags |= kInvisible;
191 if (style.strikethrough) flags |= kStrikethrough;
192 if (style.overline) flags |= kOverline;
193 if (style.underline != 0) flags |= kUnderline;
194 if (selected) flags |= kSelected;
195 return flags;
196}
197
198} // namespace
199
200extern "C" JNIEXPORT jlong JNICALL
201Java_expo_modules_t3terminal_GhosttyBridge_nativeCreate(
202 JNIEnv* env, jclass, jint cols, jint rows, jint cell_width, jint cell_height,
203 jint foreground, jint background, jint cursor, jintArray palette) {
204 auto* session = new Session();
205 GhosttyTerminalOptions options = {
206 .cols = static_cast<uint16_t>(std::clamp(cols, 1, 65535)),
207 .rows = static_cast<uint16_t>(std::clamp(rows, 1, 65535)),
208 .max_scrollback = kMaxScrollbackRows,
209 };
210 if (ghostty_terminal_new(nullptr, &session->terminal, options) != GHOSTTY_SUCCESS ||
211 ghostty_render_state_new(nullptr, &session->render_state) != GHOSTTY_SUCCESS ||
212 ghostty_render_state_row_iterator_new(nullptr, &session->row_iterator) != GHOSTTY_SUCCESS ||
213 ghostty_render_state_row_cells_new(nullptr, &session->row_cells) != GHOSTTY_SUCCESS) {
214 FreeSession(session);
215 return 0;
216 }
217
218 ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_USERDATA, session);
219 ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY,
220 reinterpret_cast<const void*>(OnWritePty));
221 ApplyTheme(session, foreground, background, cursor, env, palette);
222 ghostty_terminal_resize(session->terminal, options.cols, options.rows,
223 static_cast<uint32_t>(std::max(cell_width, 1)),
224 static_cast<uint32_t>(std::max(cell_height, 1)));
225 return static_cast<jlong>(reinterpret_cast<intptr_t>(session));
226}
227
228extern "C" JNIEXPORT void JNICALL
229Java_expo_modules_t3terminal_GhosttyBridge_nativeDestroy(JNIEnv*, jclass, jlong handle) {
230 FreeSession(FromHandle(handle));
231}
232
233extern "C" JNIEXPORT jbyteArray JNICALL
234Java_expo_modules_t3terminal_GhosttyBridge_nativeFeed(JNIEnv* env, jclass, jlong handle,
235 jbyteArray data) {
236 auto* session = FromHandle(handle);
237 if (session == nullptr || data == nullptr) return env->NewByteArray(0);
238 std::lock_guard<std::mutex> lock(session->mutex);
239 const auto length = env->GetArrayLength(data);
240 std::vector<uint8_t> bytes(static_cast<size_t>(length));
241 if (length > 0) {
242 env->GetByteArrayRegion(data, 0, length, reinterpret_cast<jbyte*>(bytes.data()));
243 ghostty_terminal_vt_write(session->terminal, bytes.data(), bytes.size());
244 }
245 return ToJavaBytes(env, DrainResponses(session));
246}
247
248extern "C" JNIEXPORT jbyteArray JNICALL
249Java_expo_modules_t3terminal_GhosttyBridge_nativeResize(
250 JNIEnv* env, jclass, jlong handle, jint cols, jint rows, jint cell_width, jint cell_height) {
251 auto* session = FromHandle(handle);
252 if (session == nullptr) return env->NewByteArray(0);
253 std::lock_guard<std::mutex> lock(session->mutex);
254 ghostty_terminal_resize(session->terminal,
255 static_cast<uint16_t>(std::clamp(cols, 1, 65535)),
256 static_cast<uint16_t>(std::clamp(rows, 1, 65535)),
257 static_cast<uint32_t>(std::max(cell_width, 1)),
258 static_cast<uint32_t>(std::max(cell_height, 1)));
259 return ToJavaBytes(env, DrainResponses(session));
260}
261
262extern "C" JNIEXPORT void JNICALL
263Java_expo_modules_t3terminal_GhosttyBridge_nativeScroll(JNIEnv*, jclass, jlong handle,
264 jint rows) {
265 auto* session = FromHandle(handle);
266 if (session == nullptr || rows == 0) return;
267 std::lock_guard<std::mutex> lock(session->mutex);
268 GhosttyTerminalScrollViewport scroll = {
269 .tag = GHOSTTY_SCROLL_VIEWPORT_DELTA,
270 .value = {.delta = rows},
271 };
272 ghostty_terminal_scroll_viewport(session->terminal, scroll);
273}
274
275extern "C" JNIEXPORT void JNICALL
276Java_expo_modules_t3terminal_GhosttyBridge_nativeSetTheme(
277 JNIEnv* env, jclass, jlong handle, jint foreground, jint background, jint cursor,
278 jintArray palette) {
279 auto* session = FromHandle(handle);
280 if (session == nullptr) return;
281 std::lock_guard<std::mutex> lock(session->mutex);
282 ApplyTheme(session, foreground, background, cursor, env, palette);
283}
284
285extern "C" JNIEXPORT jboolean JNICALL
286Java_expo_modules_t3terminal_GhosttyBridge_nativeSelectWordAt(JNIEnv*, jclass,
287 jlong handle, jint x,
288 jint y) {
289 auto* session = FromHandle(handle);
290 if (session == nullptr) return JNI_FALSE;
291 std::lock_guard<std::mutex> lock(session->mutex);
292 GhosttyTerminalSelectWordOptions options{};
293 options.size = sizeof(options);
294 if (!ViewportGridRef(session, x, y, &options.ref)) return JNI_FALSE;
295 GhosttySelection selection{};
296 selection.size = sizeof(selection);
297 if (ghostty_terminal_select_word(session->terminal, &options, &selection) !=
298 GHOSTTY_SUCCESS) {
299 return JNI_FALSE;
300 }
301 return ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION,
302 &selection) == GHOSTTY_SUCCESS
303 ? JNI_TRUE
304 : JNI_FALSE;
305}
306
307extern "C" JNIEXPORT void JNICALL
308Java_expo_modules_t3terminal_GhosttyBridge_nativeExtendSelection(
309 JNIEnv*, jclass, jlong handle, jint anchor_x, jint anchor_y, jint x, jint y) {
310 auto* session = FromHandle(handle);
311 if (session == nullptr) return;
312 std::lock_guard<std::mutex> lock(session->mutex);
313 GhosttySelection selection{};
314 selection.size = sizeof(selection);
315 if (!ViewportGridRef(session, anchor_x, anchor_y, &selection.start)) return;
316 if (!ViewportGridRef(session, x, y, &selection.end)) return;
317 ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection);
318}
319
320extern "C" JNIEXPORT jboolean JNICALL
321Java_expo_modules_t3terminal_GhosttyBridge_nativeSelectAll(JNIEnv*, jclass,
322 jlong handle) {
323 auto* session = FromHandle(handle);
324 if (session == nullptr) return JNI_FALSE;
325 std::lock_guard<std::mutex> lock(session->mutex);
326 GhosttySelection selection{};
327 selection.size = sizeof(selection);
328 if (ghostty_terminal_select_all(session->terminal, &selection) != GHOSTTY_SUCCESS) {
329 return JNI_FALSE;
330 }
331 return ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION,
332 &selection) == GHOSTTY_SUCCESS
333 ? JNI_TRUE
334 : JNI_FALSE;
335}
336
337extern "C" JNIEXPORT void JNICALL
338Java_expo_modules_t3terminal_GhosttyBridge_nativeClearSelection(JNIEnv*, jclass,
339 jlong handle) {
340 auto* session = FromHandle(handle);
341 if (session == nullptr) return;
342 std::lock_guard<std::mutex> lock(session->mutex);
343 ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, nullptr);
344}
345
346// Returns the active selection as UTF-8 bytes (soft-wrapped lines unwrapped,
347// trailing whitespace trimmed), or null when there is no selection.
348extern "C" JNIEXPORT jbyteArray JNICALL
349Java_expo_modules_t3terminal_GhosttyBridge_nativeGetSelectionText(JNIEnv* env, jclass,
350 jlong handle) {
351 auto* session = FromHandle(handle);
352 if (session == nullptr) return nullptr;
353 std::lock_guard<std::mutex> lock(session->mutex);
354 GhosttyTerminalSelectionFormatOptions options{};
355 options.size = sizeof(options);
356 options.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
357 options.unwrap = true;
358 options.trim = true;
359 uint8_t* bytes = nullptr;
360 size_t len = 0;
361 if (ghostty_terminal_selection_format_alloc(session->terminal, nullptr, options,
362 &bytes, &len) != GHOSTTY_SUCCESS ||
363 bytes == nullptr) {
364 return nullptr;
365 }
366 auto result = env->NewByteArray(static_cast<jsize>(len));
367 if (result != nullptr && len > 0) {
368 env->SetByteArrayRegion(result, 0, static_cast<jsize>(len),
369 reinterpret_cast<const jbyte*>(bytes));
370 }
371 ghostty_free(nullptr, bytes, len);
372 return result;
373}
374
375extern "C" JNIEXPORT jbyteArray JNICALL
376Java_expo_modules_t3terminal_GhosttyBridge_nativeSnapshot(JNIEnv* env, jclass,
377 jlong handle) {
378 auto* session = FromHandle(handle);
379 if (session == nullptr) return env->NewByteArray(0);
380 std::lock_guard<std::mutex> lock(session->mutex);
381
382 if (ghostty_render_state_update(session->render_state, session->terminal) != GHOSTTY_SUCCESS) {
383 return env->NewByteArray(0);
384 }
385
386 uint16_t cols = 0;
387 uint16_t rows = 0;
388 bool cursor_visible = false;
389 bool cursor_in_viewport = false;
390 bool cursor_blinking = false;
391 uint16_t cursor_x = 0xFFFF;
392 uint16_t cursor_y = 0xFFFF;
393 GhosttyRenderStateCursorVisualStyle cursor_style =
394 GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK;
395 ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_COLS, &cols);
396 ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_ROWS, &rows);
397 ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE,
398 &cursor_visible);
399 ghostty_render_state_get(session->render_state,
400 GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE,
401 &cursor_in_viewport);
402 ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING,
403 &cursor_blinking);
404 ghostty_render_state_get(session->render_state,
405 GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE, &cursor_style);
406 if (cursor_in_viewport) {
407 ghostty_render_state_get(session->render_state,
408 GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X, &cursor_x);
409 ghostty_render_state_get(session->render_state,
410 GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y, &cursor_y);
411 }
412
413 GhosttyRenderStateColors colors{};
414 colors.size = sizeof(colors);
415 if (ghostty_render_state_colors_get(session->render_state, &colors) != GHOSTTY_SUCCESS) {
416 return env->NewByteArray(0);
417 }
418 const auto cursor_color = colors.cursor_has_value ? colors.cursor : colors.foreground;
419
420 ByteWriter writer(32 + static_cast<size_t>(cols) * rows * 14);
421 writer.U32(kSnapshotMagic);
422 writer.U16(kSnapshotVersion);
423 writer.U16(cols);
424 writer.U16(rows);
425 writer.U16(cursor_x);
426 writer.U16(cursor_y);
427 writer.U8(cursor_visible && cursor_in_viewport ? 1 : 0);
428 writer.U8(static_cast<uint8_t>(cursor_style));
429 writer.U8(cursor_blinking ? 1 : 0);
430 writer.U8(0);
431 writer.U32(ArgbFromRgb(colors.foreground));
432 writer.U32(ArgbFromRgb(colors.background));
433 writer.U32(ArgbFromRgb(cursor_color));
434
435 if (ghostty_render_state_get(session->render_state,
436 GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR,
437 &session->row_iterator) != GHOSTTY_SUCCESS) {
438 return env->NewByteArray(0);
439 }
440
441 uint16_t written_rows = 0;
442 while (written_rows < rows &&
443 ghostty_render_state_row_iterator_next(session->row_iterator)) {
444 if (ghostty_render_state_row_get(session->row_iterator,
445 GHOSTTY_RENDER_STATE_ROW_DATA_CELLS,
446 &session->row_cells) != GHOSTTY_SUCCESS) {
447 break;
448 }
449
450 uint16_t written_cols = 0;
451 while (written_cols < cols && ghostty_render_state_row_cells_next(session->row_cells)) {
452 GhosttyStyle style{};
453 style.size = sizeof(style);
454 bool selected = false;
455 GhosttyColorRgb foreground = colors.foreground;
456 GhosttyColorRgb background = colors.background;
457 ghostty_render_state_row_cells_get(session->row_cells,
458 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE,
459 &style);
460 ghostty_render_state_row_cells_get(session->row_cells,
461 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED,
462 &selected);
463 ghostty_render_state_row_cells_get(session->row_cells,
464 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR,
465 &foreground);
466 ghostty_render_state_row_cells_get(session->row_cells,
467 GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR,
468 &background);
469 if (style.inverse) std::swap(foreground, background);
470 if (style.faint) foreground = Blend(foreground, background, 155);
471
472 uint32_t grapheme_count = 0;
473 ghostty_render_state_row_cells_get(
474 session->row_cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN,
475 &grapheme_count);
476 std::vector<uint8_t> utf8;
477 if (grapheme_count > 0) {
478 std::vector<uint32_t> codepoints(grapheme_count);
479 if (ghostty_render_state_row_cells_get(
480 session->row_cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF,
481 codepoints.data()) == GHOSTTY_SUCCESS) {
482 utf8.reserve(grapheme_count * 4);
483 for (const auto codepoint : codepoints) AppendUtf8(&utf8, codepoint);
484 }
485 }
486
487 const auto text_length = static_cast<uint16_t>(std::min<size_t>(utf8.size(), 65535));
488 writer.U32(ArgbFromRgb(foreground));
489 writer.U32(ArgbFromRgb(background));
490 writer.U16(StyleFlags(style, selected));
491 writer.U16(text_length);
492 if (text_length != utf8.size()) utf8.resize(text_length);
493 writer.Bytes(utf8);
494 ++written_cols;
495 }
496
497 while (written_cols++ < cols) {
498 writer.U32(ArgbFromRgb(colors.foreground));
499 writer.U32(ArgbFromRgb(colors.background));
500 writer.U16(0);
501 writer.U16(0);
502 }
503 ++written_rows;
504 }
505
506 while (written_rows++ < rows) {
507 for (uint16_t column = 0; column < cols; ++column) {
508 writer.U32(ArgbFromRgb(colors.foreground));
509 writer.U32(ArgbFromRgb(colors.background));
510 writer.U16(0);
511 writer.U16(0);
512 }
513 }
514
515 return ToJavaBytes(env, writer.Take());
516}
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt created+64
...@@ -0,0 +1,64 @@
1package expo.modules.t3terminal
2
3internal object GhosttyBridge {
4 init {
5 System.loadLibrary("ghostty-vt")
6 System.loadLibrary("t3terminal")
7 }
8
9 @JvmStatic
10 @Suppress("LongParameterList")
11 external fun nativeCreate(
12 cols: Int,
13 rows: Int,
14 cellWidth: Int,
15 cellHeight: Int,
16 foreground: Int,
17 background: Int,
18 cursor: Int,
19 palette: IntArray
20 ): Long
21
22 @JvmStatic external fun nativeDestroy(handle: Long)
23
24 @JvmStatic external fun nativeFeed(handle: Long, data: ByteArray): ByteArray
25
26 @JvmStatic
27 external fun nativeResize(
28 handle: Long,
29 cols: Int,
30 rows: Int,
31 cellWidth: Int,
32 cellHeight: Int
33 ): ByteArray
34
35 @JvmStatic external fun nativeScroll(handle: Long, rows: Int)
36
37 @JvmStatic
38 external fun nativeSetTheme(
39 handle: Long,
40 foreground: Int,
41 background: Int,
42 cursor: Int,
43 palette: IntArray
44 )
45
46 @JvmStatic external fun nativeSnapshot(handle: Long): ByteArray
47
48 @JvmStatic external fun nativeSelectWordAt(handle: Long, col: Int, row: Int): Boolean
49
50 @JvmStatic
51 external fun nativeExtendSelection(
52 handle: Long,
53 anchorCol: Int,
54 anchorRow: Int,
55 col: Int,
56 row: Int
57 )
58
59 @JvmStatic external fun nativeSelectAll(handle: Long): Boolean
60
61 @JvmStatic external fun nativeClearSelection(handle: Long)
62
63 @JvmStatic external fun nativeGetSelectionText(handle: Long): ByteArray?
64}
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt+4
...@@ -51,6 +51,10 @@ class T3TerminalModule : Module() {...@@ -51,6 +51,10 @@ class T3TerminalModule : Module() {
51 }51 }
5252
53 Events("onInput", "onResize")53 Events("onInput", "onResize")
54
55 OnViewDestroys { view: T3TerminalView ->
56 view.cleanup()
57 }
54 }58 }
55 }59 }
56}60}
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt+295-104
...@@ -3,65 +3,70 @@ package expo.modules.t3terminal...@@ -3,65 +3,70 @@ package expo.modules.t3terminal
3import android.content.Context3import android.content.Context
4import android.graphics.Color4import android.graphics.Color
5import android.graphics.Typeface5import android.graphics.Typeface
6import android.view.View6import android.text.Editable
7import android.text.InputType
8import android.text.TextWatcher
9import android.view.KeyEvent
7import android.view.ViewGroup10import android.view.ViewGroup
8import android.view.inputmethod.InputMethodManager
9import android.view.inputmethod.EditorInfo11import android.view.inputmethod.EditorInfo
12import android.view.inputmethod.InputMethodManager
10import android.widget.EditText13import android.widget.EditText
11import android.widget.LinearLayout14import android.widget.FrameLayout
12import android.widget.ScrollView
13import android.widget.TextView
14import androidx.core.widget.doAfterTextChanged
15import expo.modules.kotlin.AppContext15import expo.modules.kotlin.AppContext
16import expo.modules.kotlin.views.ExpoView
17import expo.modules.kotlin.viewevent.EventDispatcher16import expo.modules.kotlin.viewevent.EventDispatcher
17import expo.modules.kotlin.views.ExpoView
18import kotlin.math.max18import kotlin.math.max
19import kotlin.math.min
2019
21class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {20class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
22 private val container = LinearLayout(context)21 private val container = FrameLayout(context)
23 private val scrollView = ScrollView(context)22 private val terminalCanvas = TerminalCanvasView(context)
24 private val textView = TextView(context)
25 private val inputView = EditText(context)23 private val inputView = EditText(context)
26 private val onInput by EventDispatcher()24 private val onInput by EventDispatcher()
27 private val onResize by EventDispatcher()25 private val onResize by EventDispatcher()
28 private var lastWidth = 026 private var terminalHandle = 0L
29 private var lastHeight = 027 private var fedBuffer = ""
28 private var cols = 0
29 private var rows = 0
30 private var clearingInput = false30 private var clearingInput = false
31 private var isCleanedUp = false
31 private var backgroundColorValue = Color.parseColor("#24292E")32 private var backgroundColorValue = Color.parseColor("#24292E")
32 private var foregroundColorValue = Color.parseColor("#D1D5DA")33 private var foregroundColorValue = Color.parseColor("#D1D5DA")
33 private var mutedForegroundColorValue = Color.parseColor("#959DA5")34 private var mutedForegroundColorValue = Color.parseColor("#959DA5")
35 private var cursorColorValue = Color.parseColor("#009FFF")
36 private var paletteColors = IntArray(0)
3437
35 var terminalKey: String = ""38 var terminalKey: String = ""
36 set(value) {39 set(value) {
40 if (field == value) return
37 field = value41 field = value
38 contentDescription = "t3-terminal-$value"42 contentDescription = "t3-terminal-$value"
43 recreateTerminal()
39 }44 }
4045
41 var initialBuffer: String = ""46 var initialBuffer: String = ""
42 set(value) {47 set(value) {
48 if (field == value) return
43 field = value49 field = value
44 textView.text = value.ifEmpty { "$ " }50 feedPendingBuffer()
45 scrollView.post {
46 scrollView.fullScroll(View.FOCUS_DOWN)
47 }
48 }51 }
4952
50 var fontSize: Float = 10f53 var fontSize: Float = 10f
51 set(value) {54 set(value) {
52 field = value55 field = value
53 textView.textSize = value56 terminalCanvas.fontSizeSp = value
54 inputView.textSize = max(value, 13f)57 inputView.textSize = max(value, 13f)
55 emitResize()58 emitResize()
56 }59 }
5760
58 var appearanceScheme: String = "dark"61 var appearanceScheme: String = "dark"
62
63 var themeConfig: String = ""
59 set(value) {64 set(value) {
60 field = value65 field = value
66 parseThemeConfig(value)
67 applyTheme()
61 }68 }
6269
63 var themeConfig: String = ""
64
65 var focusRequest: Double = 0.070 var focusRequest: Double = 0.0
66 set(value) {71 set(value) {
67 val previous = field72 val previous = field
...@@ -89,129 +94,320 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex...@@ -89,129 +94,320 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
89 set(value) {94 set(value) {
90 field = value95 field = value
91 mutedForegroundColorValue = parseColor(value, mutedForegroundColorValue)96 mutedForegroundColorValue = parseColor(value, mutedForegroundColorValue)
92 applyTheme()
93 }97 }
9498
95 init {99 init {
100 terminalCanvas.fontSizeSp = fontSize
101 terminalCanvas.onRequestKeyboard = { requestKeyboardFocus() }
102 terminalCanvas.onScrollRows = { delta ->
103 if (terminalHandle != 0L) {
104 GhosttyBridge.nativeScroll(terminalHandle, delta)
105 renderSnapshot()
106 }
107 }
108 terminalCanvas.onCellMetricsChanged = { emitResize() }
109 terminalCanvas.selectionDelegate = object : TerminalSelectionDelegate {
110 override fun selectWordAt(col: Int, row: Int): Boolean {
111 if (terminalHandle == 0L) return false
112 val selected = GhosttyBridge.nativeSelectWordAt(terminalHandle, col, row)
113 if (selected) renderSnapshot()
114 return selected
115 }
116
117 override fun extendSelection(anchorCol: Int, anchorRow: Int, col: Int, row: Int) {
118 if (terminalHandle == 0L) return
119 GhosttyBridge.nativeExtendSelection(terminalHandle, anchorCol, anchorRow, col, row)
120 renderSnapshot()
121 }
122
123 override fun selectAll(): Boolean {
124 if (terminalHandle == 0L) return false
125 val selected = GhosttyBridge.nativeSelectAll(terminalHandle)
126 if (selected) renderSnapshot()
127 return selected
128 }
129
130 override fun clearSelection() {
131 if (terminalHandle == 0L) return
132 GhosttyBridge.nativeClearSelection(terminalHandle)
133 renderSnapshot()
134 }
135
136 override fun selectionText(): String? =
137 if (terminalHandle == 0L) {
138 null
139 } else {
140 GhosttyBridge.nativeGetSelectionText(terminalHandle)?.let { String(it, Charsets.UTF_8) }
141 }
142 }
143
144 configureInputView()
145 container.addView(
146 terminalCanvas,
147 FrameLayout.LayoutParams(
148 ViewGroup.LayoutParams.MATCH_PARENT,
149 ViewGroup.LayoutParams.MATCH_PARENT,
150 ),
151 )
152 container.addView(inputView, FrameLayout.LayoutParams(1, 1))
153 addView(
154 container,
155 LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
156 )
96 applyTheme()157 applyTheme()
97 container.orientation = LinearLayout.VERTICAL158 }
98 textView.typeface = Typeface.MONOSPACE
99 textView.textSize = fontSize
100 textView.setPadding(8, 8, 8, 8)
101 textView.text = "$ "
102159
160 override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
161 super.onSizeChanged(width, height, oldWidth, oldHeight)
162 if (width != oldWidth || height != oldHeight) emitResize()
163 }
164
165 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
166 super.onMeasure(widthMeasureSpec, heightMeasureSpec)
167 val childWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
168 val childHeightSpec = MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
169 container.measure(childWidthSpec, childHeightSpec)
170 }
171
172 override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
173 container.layout(0, 0, right - left, bottom - top)
174 if (changed) emitResize()
175 }
176
177 fun cleanup() {
178 if (isCleanedUp) return
179 isCleanedUp = true
180 inputView.setOnEditorActionListener(null)
181 terminalCanvas.onScrollRows = null
182 terminalCanvas.onRequestKeyboard = null
183 terminalCanvas.onCellMetricsChanged = null
184 terminalCanvas.selectionDelegate = null
185 destroyTerminal()
186 }
187
188 private fun configureInputView() {
103 inputView.setSingleLine(true)189 inputView.setSingleLine(true)
104 inputView.setTextColor(Color.TRANSPARENT)190 inputView.setTextColor(Color.TRANSPARENT)
105 inputView.setHintTextColor(Color.TRANSPARENT)191 inputView.setHintTextColor(Color.TRANSPARENT)
106 inputView.setBackgroundColor(Color.TRANSPARENT)192 inputView.setBackgroundColor(Color.TRANSPARENT)
107 inputView.typeface = Typeface.MONOSPACE193 inputView.typeface = Typeface.MONOSPACE
108 inputView.textSize = max(fontSize, 13f)194 inputView.textSize = max(fontSize, 13f)
109 inputView.hint = ""195 inputView.alpha = 0.01f
110 inputView.alpha = 0.02f196 inputView.isFocusableInTouchMode = true
111 inputView.imeOptions = EditorInfo.IME_ACTION_SEND197 inputView.imeOptions = EditorInfo.IME_ACTION_SEND or
198 EditorInfo.IME_FLAG_NO_EXTRACT_UI or
199 EditorInfo.IME_FLAG_NO_FULLSCREEN or
200 EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
201 inputView.inputType = InputType.TYPE_CLASS_TEXT or
202 InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
203 InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
112 inputView.setPadding(0, 0, 0, 0)204 inputView.setPadding(0, 0, 0, 0)
113 inputView.setOnFocusChangeListener { _, hasFocus ->205 inputView.setOnEditorActionListener { _, actionId, event ->
114 if (hasFocus) {206 val isKeyUp = event?.action == KeyEvent.ACTION_UP
115 showKeyboard()207 val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp
208 val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER &&
209 event.action == KeyEvent.ACTION_DOWN
210 val isEnter = isImeSend || isHardwareEnter
211 if (isEnter) {
212 // Enter must send CR: raw-mode TUIs treat LF as Ctrl+J (insert newline).
213 onInput(mapOf("data" to "\r"))
214 true
215 } else {
216 false
116 }217 }
117 }218 }
118 inputView.setOnEditorActionListener { view, actionId, _ ->
119 if (actionId != EditorInfo.IME_ACTION_SEND) return@setOnEditorActionListener false
120 onInput(mapOf("data" to "\n"))
121 true
122 }
123 inputView.setOnKeyListener { _, keyCode, event ->219 inputView.setOnKeyListener { _, keyCode, event ->
124 if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false220 if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false
125 when {221 when {
126 keyCode == android.view.KeyEvent.KEYCODE_DEL -> {222 keyCode == KeyEvent.KEYCODE_DEL -> {
127 onInput(mapOf("data" to "\u007F"))223 onInput(mapOf("data" to "\u007F"))
128 true224 true
129 }225 }
130 // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...).226 // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...).
131 event.isCtrlPressed &&227 event.isCtrlPressed && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> {
132 keyCode in android.view.KeyEvent.KEYCODE_A..android.view.KeyEvent.KEYCODE_Z -> {
133 onInput(228 onInput(
134 mapOf("data" to (keyCode - android.view.KeyEvent.KEYCODE_A + 1).toChar().toString()),229 mapOf("data" to (keyCode - KeyEvent.KEYCODE_A + 1).toChar().toString()),
135 )230 )
136 true231 true
137 }232 }
138 else -> false233 else -> false
139 }234 }
140 }235 }
141 inputView.doAfterTextChanged { editable ->236 inputView.addTextChangedListener(
142 if (clearingInput) return@doAfterTextChanged237 object : TextWatcher {
143 val text = editable?.toString().orEmpty()238 override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
144 if (text.isEmpty()) return@doAfterTextChanged239
145 onInput(mapOf("data" to text))240 override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
146 clearingInput = true241 if (clearingInput || s == null || count <= 0) return
147 inputView.text?.clear()242 val end = (start + count).coerceAtMost(s.length)
148 clearingInput = false243 if (start >= end) return
149 }244 val insertedText = s.subSequence(start, end).toString()
150245 if (insertedText.isNotEmpty()) {
151 textView.setOnClickListener { requestKeyboardFocus() }246 onInput(mapOf("data" to insertedText))
152 scrollView.setOnClickListener { requestKeyboardFocus() }247 }
153 container.setOnClickListener { requestKeyboardFocus() }248 }
154 isClickable = true249
155 setOnClickListener { requestKeyboardFocus() }250 override fun afterTextChanged(editable: Editable?) {
156251 if (clearingInput || editable.isNullOrEmpty()) return
157 scrollView.addView(252 clearingInput = true
158 textView,253 editable.clear()
159 LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT),254 clearingInput = false
160 )255 }
161 container.addView(256 },
162 scrollView,
163 LinearLayout.LayoutParams(
164 ViewGroup.LayoutParams.MATCH_PARENT,
165 0,
166 1f,
167 ),
168 )
169 container.addView(
170 inputView,
171 LinearLayout.LayoutParams(1, 1),
172 )257 )
173 addView(258 }
174 container,259
175 LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),260 @Suppress("ComplexCondition")
261 private fun emitResize() {
262 if (
263 width <= 0 ||
264 height <= 0 ||
265 terminalCanvas.width <= 0 ||
266 terminalCanvas.height <= 0 ||
267 isCleanedUp
268 ) {
269 return
270 }
271 val nextCols = (terminalCanvas.usableWidth() / terminalCanvas.cellWidthPx)
272 .toInt()
273 .coerceIn(2, 400)
274 val nextRows = (terminalCanvas.usableHeight() / terminalCanvas.cellHeightPx)
275 .toInt()
276 .coerceIn(2, 200)
277 if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return
278 cols = nextCols
279 rows = nextRows
280 val response = if (terminalHandle == 0L) {
281 createTerminal()
282 ByteArray(0)
283 } else {
284 GhosttyBridge.nativeResize(
285 terminalHandle,
286 cols,
287 rows,
288 terminalCanvas.cellWidthPx.toInt(),
289 terminalCanvas.cellHeightPx.toInt(),
290 )
291 }
292 emitResponse(response)
293 onResize(mapOf("cols" to cols, "rows" to rows))
294 feedPendingBuffer()
295 renderSnapshot()
296 }
297
298 @Suppress("ComplexCondition")
299 private fun createTerminal() {
300 if (terminalHandle != 0L || cols <= 0 || rows <= 0 || isCleanedUp) return
301 terminalHandle = GhosttyBridge.nativeCreate(
302 cols,
303 rows,
304 terminalCanvas.cellWidthPx.toInt(),
305 terminalCanvas.cellHeightPx.toInt(),
306 foregroundColorValue,
307 backgroundColorValue,
308 cursorColorValue,
309 paletteColors,
176 )310 )
311 fedBuffer = ""
312 }
177313
178 post {314 private fun recreateTerminal() {
179 requestKeyboardFocus()315 if (terminalHandle == 0L) return
316 destroyTerminal()
317 createTerminal()
318 feedPendingBuffer()
319 renderSnapshot()
320 }
321
322 private fun destroyTerminal() {
323 if (terminalHandle == 0L) return
324 GhosttyBridge.nativeDestroy(terminalHandle)
325 terminalHandle = 0L
326 fedBuffer = ""
327 terminalCanvas.resetSelectionState()
328 }
329
330 private fun feedPendingBuffer() {
331 if (terminalHandle == 0L || initialBuffer == fedBuffer) return
332 if (!initialBuffer.startsWith(fedBuffer)) {
333 recreateTerminal()
334 if (terminalHandle == 0L) return
180 }335 }
336 val suffix = initialBuffer.substring(fedBuffer.length)
337 if (suffix.isNotEmpty()) {
338 emitResponse(GhosttyBridge.nativeFeed(terminalHandle, suffix.toByteArray(Charsets.UTF_8)))
339 // New output invalidates an active selection (matches the web drawer);
340 // otherwise the copy toolbar drifts out of sync with the grid.
341 if (terminalCanvas.hasActiveSelection()) {
342 GhosttyBridge.nativeClearSelection(terminalHandle)
343 terminalCanvas.resetSelectionState()
344 }
345 }
346 fedBuffer = initialBuffer
347 renderSnapshot()
181 }348 }
182349
183 override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {350 private fun renderSnapshot() {
184 super.onSizeChanged(width, height, oldWidth, oldHeight)351 if (terminalHandle == 0L) return
185 if (width == lastWidth && height == lastHeight) return352 TerminalFrame.decode(
186 lastWidth = width353 GhosttyBridge.nativeSnapshot(terminalHandle)
187 lastHeight = height354 )?.let(terminalCanvas::setFrame)
188 emitResize()
189 }355 }
190356
191 private fun emitResize() {357 private fun emitResponse(response: ByteArray) {
192 if (width <= 0 || height <= 0) return358 if (response.isNotEmpty()) {
193 val density = resources.displayMetrics.scaledDensity359 onInput(mapOf("data" to String(response, Charsets.UTF_8)))
194 val fontPx = max(fontSize * density, 1f)360 }
195 val cols = max(20, min(400, (width / (fontPx * 0.62f)).toInt()))
196 val terminalHeight = max(height - inputView.height, 0)
197 val rows = max(5, min(200, (terminalHeight / (fontPx * 1.35f)).toInt()))
198 onResize(mapOf("cols" to cols, "rows" to rows))
199 }361 }
200362
201 private fun requestKeyboardFocus() {363 private fun requestKeyboardFocus() {
202 inputView.requestFocus()364 inputView.requestFocus()
203 showKeyboard()365 val inputMethodManager = context.getSystemService(
366 Context.INPUT_METHOD_SERVICE
367 ) as? InputMethodManager
368 inputMethodManager?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT)
204 }369 }
205370
206 private fun applyTheme() {371 private fun applyTheme() {
207 setBackgroundColor(backgroundColorValue)372 setBackgroundColor(backgroundColorValue)
208 container.setBackgroundColor(backgroundColorValue)373 container.setBackgroundColor(backgroundColorValue)
209 scrollView.setBackgroundColor(backgroundColorValue)374 terminalCanvas.setBackgroundColor(backgroundColorValue)
210 textView.setTextColor(foregroundColorValue)375 if (terminalHandle != 0L) {
211 textView.setBackgroundColor(backgroundColorValue)376 GhosttyBridge.nativeSetTheme(
212 inputView.setTextColor(Color.TRANSPARENT)377 terminalHandle,
213 inputView.setHintTextColor(mutedForegroundColorValue)378 foregroundColorValue,
214 inputView.setBackgroundColor(Color.TRANSPARENT)379 backgroundColorValue,
380 cursorColorValue,
381 paletteColors,
382 )
383 renderSnapshot()
384 }
385 }
386
387 @Suppress("LoopWithTooManyJumpStatements")
388 private fun parseThemeConfig(config: String) {
389 val palette = sortedMapOf<Int, Int>()
390 for (line in config.lineSequence()) {
391 val parts = line.split('=', limit = 2)
392 if (parts.size != 2) continue
393 val key = parts[0].trim()
394 val value = parts[1].trim()
395 when (key) {
396 "cursor-color" -> cursorColorValue = parseColor(value, cursorColorValue)
397 "palette" -> {
398 val paletteParts = value.split('=', limit = 2)
399 val index = paletteParts.firstOrNull()?.trim()?.toIntOrNull() ?: continue
400 val color = paletteParts.getOrNull(1)?.trim() ?: continue
401 if (index in 0..255) palette[index] = parseColor(color, foregroundColorValue)
402 }
403 }
404 }
405 if (palette.isNotEmpty()) {
406 val lastIndex = palette.lastKey()
407 paletteColors = IntArray(lastIndex + 1) { index ->
408 palette[index] ?: foregroundColorValue
409 }
410 }
215 }411 }
216412
217 private fun parseColor(value: String, fallback: Int): Int =413 private fun parseColor(value: String, fallback: Int): Int =
...@@ -220,9 +416,4 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex...@@ -220,9 +416,4 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
220 } catch (_: IllegalArgumentException) {416 } catch (_: IllegalArgumentException) {
221 fallback417 fallback
222 }418 }
223
224 private fun showKeyboard() {
225 val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
226 imm?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT)
227 }
228}419}
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt created+628
...@@ -0,0 +1,628 @@
1package expo.modules.t3terminal
2
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.graphics.Canvas
7import android.graphics.Color
8import android.graphics.Paint
9import android.graphics.Rect
10import android.graphics.Typeface
11import android.util.Log
12import android.view.ActionMode
13import android.view.GestureDetector
14import android.view.HapticFeedbackConstants
15import android.view.Menu
16import android.view.MenuItem
17import android.view.MotionEvent
18import android.view.View
19import android.widget.OverScroller
20import kotlin.math.ceil
21import kotlin.math.max
22import kotlin.math.min
23
24/**
25 * Bundled terminal font with Nerd Font glyphs (powerline, file icons).
26 * MesloLGS NF is the powerlevel10k-tuned Meslo Nerd Font patch.
27 */
28internal object TerminalTypefaces {
29 private var loaded = false
30 var regular: Typeface = Typeface.MONOSPACE
31 private set
32 var bold: Typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
33 private set
34
35 @Suppress("TooGenericExceptionCaught") // Typeface.createFromAsset exposes RuntimeException.
36 fun ensureLoaded(context: Context) {
37 if (loaded) return
38 loaded = true
39 try {
40 regular = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Regular.ttf")
41 bold = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Bold.ttf")
42 } catch (error: RuntimeException) {
43 Log.w("TerminalCanvasView", "bundled terminal font unavailable, using monospace", error)
44 }
45 }
46}
47
48/**
49 * Selection operations backed by the native terminal. The terminal owns the
50 * selection state; the canvas only drives gestures and renders the result.
51 */
52internal interface TerminalSelectionDelegate {
53 fun selectWordAt(col: Int, row: Int): Boolean
54
55 fun extendSelection(anchorCol: Int, anchorRow: Int, col: Int, row: Int)
56
57 fun selectAll(): Boolean
58
59 fun clearSelection()
60
61 fun selectionText(): String?
62}
63
64internal class TerminalCanvasView(context: Context) : View(context) {
65 companion object {
66 const val FLAG_BOLD = 1 shl 0
67 const val FLAG_ITALIC = 1 shl 1
68 const val FLAG_INVISIBLE = 1 shl 4
69 const val FLAG_STRIKETHROUGH = 1 shl 5
70 const val FLAG_OVERLINE = 1 shl 6
71 const val FLAG_UNDERLINE = 1 shl 7
72 const val FLAG_SELECTED = 1 shl 8
73
74 private const val MENU_COPY = 1
75 private const val MENU_SELECT_ALL = 2
76 private const val HANDLE_COLOR = 0xFF7AA2F7.toInt()
77 }
78
79 private val density = resources.displayMetrics.density
80 private val scaledDensity = density * resources.configuration.fontScale
81 private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG)
82
83 init {
84 TerminalTypefaces.ensureLoaded(context)
85 }
86
87 private val regularTypeface = TerminalTypefaces.regular
88 private val boldTypeface = TerminalTypefaces.bold
89 private val italicTypeface = Typeface.create(TerminalTypefaces.regular, Typeface.ITALIC)
90 private val boldItalicTypeface = Typeface.create(TerminalTypefaces.bold, Typeface.ITALIC)
91 private val gestureDetector = GestureDetector(context, TerminalGestureListener())
92 private val contentPadding = 8f * density
93 private var frame: TerminalFrame? = null
94 private var scrollRemainder = 0f
95 private val scroller = OverScroller(context)
96 private var flingLastY = 0
97 private val flingRunnable = object : Runnable {
98 override fun run() {
99 if (!scroller.computeScrollOffset()) return
100 val currentY = scroller.currY
101 val deltaPx = (currentY - flingLastY).toFloat()
102 flingLastY = currentY
103 scrollRemainder += -deltaPx / cellHeightPx
104 val rows = scrollRemainder.toInt()
105 if (rows != 0) {
106 scrollRemainder -= rows
107 onScrollRows?.invoke(rows)
108 }
109 postOnAnimation(this)
110 }
111 }
112 private var cursorOn = true
113 private val cursorBlink = object : Runnable {
114 override fun run() {
115 val currentFrame = frame ?: return
116 if (!currentFrame.cursorBlinking || !currentFrame.cursorVisible) return
117 cursorOn = !cursorOn
118 invalidate()
119 postDelayed(this, 500)
120 }
121 }
122
123 var onScrollRows: ((Int) -> Unit)? = null
124 var onRequestKeyboard: (() -> Unit)? = null
125 var onCellMetricsChanged: (() -> Unit)? = null
126 var selectionDelegate: TerminalSelectionDelegate? = null
127
128 private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG)
129 private var selectionActive = false
130 private var dragSelecting = false
131 private var draggingHandle = false
132 private var anchorCol = 0
133 private var anchorRow = 0
134 private var extentCol = 0
135 private var extentRow = 0
136
137 // Word-snapped span from the initial long-press; extending anchors to the
138 // far word edge so the word never shrinks mid-drag.
139 private var wordStartCol = 0
140 private var wordStartRow = 0
141 private var wordEndCol = 0
142 private var wordEndRow = 0
143 private var actionMode: ActionMode? = null
144
145 // Actual selection endpoints in viewport cells, derived from the decoded
146 // frame (word-snap can extend past the pressed cell). Drive handle
147 // placement and hit testing.
148 private var selectionEndpointsValid = false
149 private var selectionStartCol = 0
150 private var selectionStartRow = 0
151 private var selectionEndCol = 0
152 private var selectionEndRow = 0
153
154 var fontSizeSp: Float = 10f
155 set(value) {
156 if (field == value) return
157 field = value
158 updateCellMetrics()
159 }
160
161 var cellWidthPx: Float = 1f
162 private set
163 var cellHeightPx: Float = 1f
164 private set
165 private var baselineOffsetPx: Float = 1f
166
167 init {
168 isClickable = true
169 isFocusable = true
170 isFocusableInTouchMode = true
171 paint.typeface = regularTypeface
172 updateCellMetrics()
173 }
174
175 fun setFrame(value: TerminalFrame) {
176 frame = value
177 cursorOn = true
178 updateSelectionEndpoints()
179 removeCallbacks(cursorBlink)
180 if (value.cursorBlinking && value.cursorVisible) postDelayed(cursorBlink, 500)
181 invalidate()
182 }
183
184 fun resetSelectionState() {
185 selectionActive = false
186 dragSelecting = false
187 draggingHandle = false
188 selectionEndpointsValid = false
189 actionMode?.finish()
190 }
191
192 fun hasActiveSelection(): Boolean = selectionActive
193
194 fun usableWidth(): Float = max(width - contentPadding * 2f, 1f)
195 fun usableHeight(): Float = max(height - contentPadding * 2f, 1f)
196
197 @Suppress("NestedBlockDepth", "ComplexCondition")
198 override fun onDraw(canvas: Canvas) {
199 super.onDraw(canvas)
200 val currentFrame = frame
201 if (currentFrame == null) {
202 canvas.drawColor(Color.TRANSPARENT)
203 return
204 }
205 canvas.drawColor(currentFrame.background)
206 canvas.save()
207 canvas.clipRect(
208 contentPadding,
209 contentPadding,
210 width - contentPadding,
211 height - contentPadding,
212 )
213
214 for (row in 0 until currentFrame.rows) {
215 val top = contentPadding + row * cellHeightPx
216 val bottom = top + cellHeightPx
217 for (column in 0 until currentFrame.cols) {
218 val index = row * currentFrame.cols + column
219 val left = contentPadding + column * cellWidthPx
220 val right = left + cellWidthPx
221 val background = currentFrame.cellBackgrounds[index]
222 val flags = currentFrame.cellFlags[index]
223 paint.style = Paint.Style.FILL
224 paint.color = if (flags and FLAG_SELECTED != 0) {
225 blend(currentFrame.cursorColor, background, 0.32f)
226 } else {
227 background
228 }
229 if (paint.color != currentFrame.background || flags and FLAG_SELECTED != 0) {
230 canvas.drawRect(left, top, right + 0.5f, bottom + 0.5f, paint)
231 }
232
233 val text = currentFrame.cellText[index]
234 if (text.isNotEmpty() && flags and FLAG_INVISIBLE == 0) {
235 configureTextPaint(flags, currentFrame.cellForegrounds[index])
236 canvas.drawText(text, left, top + baselineOffsetPx, paint)
237 if (flags and FLAG_OVERLINE != 0) {
238 canvas.drawRect(left, top + 1f, right, top + max(2f, density), paint)
239 }
240 }
241 }
242 }
243
244 if (currentFrame.cursorVisible && cursorOn &&
245 currentFrame.cursorX in 0 until currentFrame.cols &&
246 currentFrame.cursorY in 0 until currentFrame.rows
247 ) {
248 drawCursor(canvas, currentFrame)
249 }
250 canvas.restore()
251 drawSelectionHandles(canvas)
252 }
253
254 override fun onTouchEvent(event: MotionEvent): Boolean {
255 if (event.actionMasked == MotionEvent.ACTION_DOWN) {
256 parent?.requestDisallowInterceptTouchEvent(true)
257 // Touch-down always stops momentum, even when the event is consumed by
258 // a selection-handle grab and never reaches the gesture detector.
259 scroller.forceFinished(true)
260 removeCallbacks(flingRunnable)
261 } else if (event.actionMasked == MotionEvent.ACTION_UP ||
262 event.actionMasked == MotionEvent.ACTION_CANCEL
263 ) {
264 parent?.requestDisallowInterceptTouchEvent(false)
265 }
266 return when {
267 dragSelecting -> {
268 when (event.actionMasked) {
269 MotionEvent.ACTION_MOVE -> extendSelectionTo(event.x, event.y)
270 MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
271 dragSelecting = false
272 showSelectionActions()
273 }
274 }
275 true
276 }
277 event.actionMasked == MotionEvent.ACTION_DOWN && grabHandleAt(event.x, event.y) -> true
278 else -> gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
279 }
280 }
281
282 override fun onDetachedFromWindow() {
283 removeCallbacks(cursorBlink)
284 removeCallbacks(flingRunnable)
285 actionMode?.finish()
286 super.onDetachedFromWindow()
287 }
288
289 private fun updateCellMetrics() {
290 paint.textSize = fontSizeSp * scaledDensity
291 paint.typeface = regularTypeface
292 cellWidthPx = ceil(paint.measureText("M").toDouble()).toFloat().coerceAtLeast(1f)
293 val metrics = paint.fontMetrics
294 val glyphHeight = metrics.descent - metrics.ascent
295 cellHeightPx = ceil((glyphHeight * 1.12f).toDouble()).toFloat().coerceAtLeast(1f)
296 baselineOffsetPx = (cellHeightPx - glyphHeight) / 2f - metrics.ascent
297 onCellMetricsChanged?.invoke()
298 invalidate()
299 }
300
301 private fun configureTextPaint(flags: Int, color: Int) {
302 val bold = flags and FLAG_BOLD != 0
303 val italic = flags and FLAG_ITALIC != 0
304 paint.typeface = when {
305 bold && italic -> boldItalicTypeface
306 bold -> boldTypeface
307 italic -> italicTypeface
308 else -> regularTypeface
309 }
310 paint.textSize = fontSizeSp * scaledDensity
311 paint.color = color
312 paint.style = Paint.Style.FILL
313 paint.isUnderlineText = flags and FLAG_UNDERLINE != 0
314 paint.isStrikeThruText = flags and FLAG_STRIKETHROUGH != 0
315 }
316
317 private fun drawCursor(canvas: Canvas, currentFrame: TerminalFrame) {
318 val left = contentPadding + currentFrame.cursorX * cellWidthPx
319 val top = contentPadding + currentFrame.cursorY * cellHeightPx
320 val right = left + cellWidthPx
321 val bottom = top + cellHeightPx
322 paint.color = currentFrame.cursorColor
323 paint.isUnderlineText = false
324 paint.isStrikeThruText = false
325 when (currentFrame.cursorStyle) {
326 0 -> canvas.drawRect(left, top, left + max(2f * density, 2f), bottom, paint)
327 2 -> canvas.drawRect(left, bottom - max(2f * density, 2f), right, bottom, paint)
328 3 -> {
329 paint.style = Paint.Style.STROKE
330 paint.strokeWidth = max(density, 1f)
331 canvas.drawRect(left, top, right, bottom, paint)
332 }
333 else -> {
334 paint.style = Paint.Style.FILL
335 canvas.drawRect(left, top, right, bottom, paint)
336 val index = currentFrame.cursorY * currentFrame.cols + currentFrame.cursorX
337 val text = currentFrame.cellText[index]
338 if (text.isNotEmpty()) {
339 configureTextPaint(currentFrame.cellFlags[index], currentFrame.background)
340 canvas.drawText(text, left, top + baselineOffsetPx, paint)
341 }
342 }
343 }
344 }
345
346 private fun columnAt(px: Float): Int {
347 val cols = frame?.cols ?: return 0
348 return ((px - contentPadding) / cellWidthPx).toInt().coerceIn(0, max(cols - 1, 0))
349 }
350
351 private fun rowAt(py: Float): Int {
352 val rows = frame?.rows ?: return 0
353 return ((py - contentPadding) / cellHeightPx).toInt().coerceIn(0, max(rows - 1, 0))
354 }
355
356 private fun startWordSelection(px: Float, py: Float) {
357 val delegate = selectionDelegate ?: return
358 val col = columnAt(px)
359 val row = rowAt(py)
360 // Set before selectWordAt: the delegate re-renders synchronously and
361 // updateSelectionEndpoints only scans while a selection is active.
362 selectionActive = true
363 if (!delegate.selectWordAt(col, row)) {
364 selectionActive = false
365 return
366 }
367 dragSelecting = true
368 draggingHandle = false
369 if (selectionEndpointsValid) {
370 wordStartCol = selectionStartCol
371 wordStartRow = selectionStartRow
372 wordEndCol = selectionEndCol
373 wordEndRow = selectionEndRow
374 } else {
375 wordStartCol = col
376 wordStartRow = row
377 wordEndCol = col
378 wordEndRow = row
379 }
380 anchorCol = wordStartCol
381 anchorRow = wordStartRow
382 extentCol = col
383 extentRow = row
384 performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
385 }
386
387 private fun extendSelectionTo(px: Float, py: Float) {
388 if (!selectionActive) return
389 val col = columnAt(px)
390 val row = rowAt(py)
391 if (col == extentCol && row == extentRow) return
392 extentCol = col
393 extentRow = row
394 if (!draggingHandle) {
395 val beforeWord = row < wordStartRow || (row == wordStartRow && col < wordStartCol)
396 if (beforeWord) {
397 anchorCol = wordEndCol
398 anchorRow = wordEndRow
399 } else {
400 anchorCol = wordStartCol
401 anchorRow = wordStartRow
402 }
403 }
404 selectionDelegate?.extendSelection(anchorCol, anchorRow, col, row)
405 }
406
407 private fun clearSelection() {
408 if (!selectionActive) return
409 selectionActive = false
410 dragSelecting = false
411 draggingHandle = false
412 selectionEndpointsValid = false
413 actionMode?.finish()
414 selectionDelegate?.clearSelection()
415 }
416
417 private fun copySelection() {
418 val text = selectionDelegate?.selectionText() ?: return
419 if (text.isEmpty()) return
420 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
421 clipboard?.setPrimaryClip(ClipData.newPlainText("Terminal", text))
422 }
423
424 private fun handleCenterX(col: Int, leadingEdge: Boolean): Float =
425 contentPadding + (col + if (leadingEdge) 0 else 1) * cellWidthPx
426
427 private fun handleCenterY(row: Int): Float =
428 contentPadding + (row + 1) * cellHeightPx + handleRadius()
429
430 private fun handleRadius(): Float = max(cellHeightPx * 0.45f, 12f)
431
432 /**
433 * Begin dragging when the touch lands on a selection handle. The opposite
434 * endpoint becomes the drag anchor so the grabbed end follows the finger.
435 */
436 private fun grabHandleAt(px: Float, py: Float): Boolean {
437 if (!selectionActive || !selectionEndpointsValid) return false
438 val slop = max(handleRadius() * 2f, 24 * density)
439
440 fun near(cx: Float, cy: Float): Boolean {
441 val dx = px - cx
442 val dy = py - cy
443 return dx * dx + dy * dy <= slop * slop
444 }
445
446 val startGrabbed =
447 near(handleCenterX(selectionStartCol, true), handleCenterY(selectionStartRow))
448 val endGrabbed =
449 !startGrabbed && near(handleCenterX(selectionEndCol, false), handleCenterY(selectionEndRow))
450 val handleGrabbed = startGrabbed || endGrabbed
451 if (handleGrabbed) {
452 if (startGrabbed) {
453 anchorCol = selectionEndCol
454 anchorRow = selectionEndRow
455 extentCol = selectionStartCol
456 extentRow = selectionStartRow
457 } else {
458 anchorCol = selectionStartCol
459 anchorRow = selectionStartRow
460 extentCol = selectionEndCol
461 extentRow = selectionEndRow
462 }
463 dragSelecting = true
464 draggingHandle = true
465 actionMode?.finish()
466 }
467 return handleGrabbed
468 }
469
470 /** Scan the decoded frame for the first/last selected cells. */
471 private fun updateSelectionEndpoints() {
472 selectionEndpointsValid = false
473 val currentFrame = frame
474 if (!selectionActive || currentFrame == null) return
475 val totalCells = currentFrame.cols * currentFrame.rows
476 var first = -1
477 var last = -1
478 for (index in 0 until totalCells) {
479 if (currentFrame.cellFlags[index] and FLAG_SELECTED != 0) {
480 if (first < 0) first = index
481 last = index
482 }
483 }
484 if (first >= 0 && currentFrame.cols > 0) {
485 selectionStartCol = first % currentFrame.cols
486 selectionStartRow = first / currentFrame.cols
487 selectionEndCol = last % currentFrame.cols
488 selectionEndRow = last / currentFrame.cols
489 selectionEndpointsValid = true
490 }
491 }
492
493 // Anchor the toolbar to the actual word-snapped endpoints when known;
494 // gesture cells can lag behind what the terminal selected.
495 private fun selectionBounds(): Rect {
496 val startCol = if (selectionEndpointsValid) selectionStartCol else min(anchorCol, extentCol)
497 val endCol = if (selectionEndpointsValid) selectionEndCol else max(anchorCol, extentCol)
498 val startRow = if (selectionEndpointsValid) selectionStartRow else min(anchorRow, extentRow)
499 val endRow = if (selectionEndpointsValid) selectionEndRow else max(anchorRow, extentRow)
500 val left = contentPadding + min(startCol, endCol) * cellWidthPx
501 val right = contentPadding + (max(startCol, endCol) + 1) * cellWidthPx
502 val top = contentPadding + startRow * cellHeightPx
503 val bottom = contentPadding + (endRow + 1) * cellHeightPx
504 return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
505 }
506
507 private fun showSelectionActions() {
508 if (actionMode != null || !selectionActive) return
509 actionMode = startActionMode(
510 object : ActionMode.Callback2() {
511 override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
512 menu.add(Menu.NONE, MENU_COPY, 0, android.R.string.copy)
513 menu.add(Menu.NONE, MENU_SELECT_ALL, 1, android.R.string.selectAll)
514 return true
515 }
516
517 override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false
518
519 override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean =
520 when (item.itemId) {
521 MENU_COPY -> {
522 copySelection()
523 clearSelection()
524 true
525 }
526 MENU_SELECT_ALL -> {
527 val currentFrame = frame
528 if (currentFrame != null && selectionDelegate?.selectAll() == true) {
529 anchorCol = 0
530 anchorRow = 0
531 extentCol = max(currentFrame.cols - 1, 0)
532 extentRow = max(currentFrame.rows - 1, 0)
533 }
534 true
535 }
536 else -> false
537 }
538
539 override fun onDestroyActionMode(mode: ActionMode) {
540 actionMode = null
541 // Dismissing the toolbar (e.g. Back) drops the selection too —
542 // except mid-drag, where grabHandleAt finishes the mode on purpose.
543 if (selectionActive && !dragSelecting) clearSelection()
544 }
545
546 override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) {
547 outRect.set(selectionBounds())
548 }
549 },
550 ActionMode.TYPE_FLOATING,
551 )
552 }
553
554 private fun drawSelectionHandles(canvas: Canvas) {
555 if (!selectionActive || !selectionEndpointsValid) return
556 val radius = handleRadius()
557 handlePaint.color = HANDLE_COLOR
558 val stemWidth = max(radius / 4f, 2f)
559
560 fun drawHandle(cx: Float, row: Int) {
561 val cornerY = contentPadding + (row + 1) * cellHeightPx
562 val cy = handleCenterY(row)
563 canvas.drawRect(cx - stemWidth / 2f, cornerY, cx + stemWidth / 2f, cy, handlePaint)
564 canvas.drawCircle(cx, cy, radius, handlePaint)
565 }
566
567 drawHandle(handleCenterX(selectionStartCol, true), selectionStartRow)
568 drawHandle(handleCenterX(selectionEndCol, false), selectionEndRow)
569 }
570
571 private fun blend(foreground: Int, background: Int, amount: Float): Int {
572 val inverseAmount = 1f - amount
573 return Color.rgb(
574 (Color.red(foreground) * amount + Color.red(background) * inverseAmount).toInt(),
575 (Color.green(foreground) * amount + Color.green(background) * inverseAmount).toInt(),
576 (Color.blue(foreground) * amount + Color.blue(background) * inverseAmount).toInt(),
577 )
578 }
579
580 private inner class TerminalGestureListener : GestureDetector.SimpleOnGestureListener() {
581 override fun onDown(event: MotionEvent): Boolean {
582 scroller.forceFinished(true)
583 removeCallbacks(flingRunnable)
584 onRequestKeyboard?.invoke()
585 return true
586 }
587
588 override fun onSingleTapUp(event: MotionEvent): Boolean {
589 if (selectionActive) {
590 clearSelection()
591 } else {
592 performClick()
593 }
594 return true
595 }
596
597 override fun onLongPress(event: MotionEvent) {
598 startWordSelection(event.x, event.y)
599 }
600
601 override fun onScroll(
602 first: MotionEvent?,
603 current: MotionEvent,
604 distanceX: Float,
605 distanceY: Float
606 ): Boolean {
607 scrollRemainder += distanceY / cellHeightPx
608 val rows = scrollRemainder.toInt()
609 if (rows != 0) {
610 scrollRemainder -= rows
611 onScrollRows?.invoke(rows)
612 }
613 return true
614 }
615
616 override fun onFling(
617 first: MotionEvent?,
618 current: MotionEvent,
619 velocityX: Float,
620 velocityY: Float
621 ): Boolean {
622 flingLastY = 0
623 scroller.fling(0, 0, 0, velocityY.toInt(), 0, 0, Int.MIN_VALUE / 2, Int.MAX_VALUE / 2)
624 postOnAnimation(flingRunnable)
625 return true
626 }
627 }
628}
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalFrame.kt created+82
...@@ -0,0 +1,82 @@
1package expo.modules.t3terminal
2
3import java.nio.ByteBuffer
4import java.nio.ByteOrder
5
6internal data class TerminalFrame(
7 val cols: Int,
8 val rows: Int,
9 val cursorX: Int,
10 val cursorY: Int,
11 val cursorVisible: Boolean,
12 val cursorStyle: Int,
13 val cursorBlinking: Boolean,
14 val foreground: Int,
15 val background: Int,
16 val cursorColor: Int,
17 val cellForegrounds: IntArray,
18 val cellBackgrounds: IntArray,
19 val cellFlags: IntArray,
20 val cellText: Array<String>
21) {
22 companion object {
23 private const val MAGIC = 0x54563354
24 private const val VERSION = 1
25 private const val HEADER_BYTES = 32
26 private const val CELL_HEADER_BYTES = 12
27
28 @Suppress("ReturnCount")
29 fun decode(bytes: ByteArray): TerminalFrame? {
30 if (bytes.size < HEADER_BYTES) return null
31 val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
32 if (buffer.int != MAGIC || buffer.short.toInt() != VERSION) return null
33
34 val cols = buffer.short.toInt() and 0xFFFF
35 val rows = buffer.short.toInt() and 0xFFFF
36 val cursorX = buffer.short.toInt() and 0xFFFF
37 val cursorY = buffer.short.toInt() and 0xFFFF
38 val cursorVisible = buffer.get().toInt() != 0
39 val cursorStyle = buffer.get().toInt() and 0xFF
40 val cursorBlinking = buffer.get().toInt() != 0
41 buffer.get()
42 val foreground = buffer.int
43 val background = buffer.int
44 val cursorColor = buffer.int
45 val cellCount = cols * rows
46 val foregrounds = IntArray(cellCount)
47 val backgrounds = IntArray(cellCount)
48 val flags = IntArray(cellCount)
49 val text = Array(cellCount) { "" }
50
51 for (index in 0 until cellCount) {
52 if (buffer.remaining() < CELL_HEADER_BYTES) return null
53 foregrounds[index] = buffer.int
54 backgrounds[index] = buffer.int
55 flags[index] = buffer.short.toInt() and 0xFFFF
56 val textLength = buffer.short.toInt() and 0xFFFF
57 if (buffer.remaining() < textLength) return null
58 if (textLength > 0) {
59 text[index] = String(bytes, buffer.position(), textLength, Charsets.UTF_8)
60 buffer.position(buffer.position() + textLength)
61 }
62 }
63
64 return TerminalFrame(
65 cols = cols,
66 rows = rows,
67 cursorX = cursorX,
68 cursorY = cursorY,
69 cursorVisible = cursorVisible,
70 cursorStyle = cursorStyle,
71 cursorBlinking = cursorBlinking,
72 foreground = foreground,
73 background = background,
74 cursorColor = cursorColor,
75 cellForegrounds = foregrounds,
76 cellBackgrounds = backgrounds,
77 cellFlags = flags,
78 cellText = text,
79 )
80 }
81 }
82}
apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so created
Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so differ
apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so created
Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so differ
apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so created
Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so differ
apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so created
Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so differ
apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh created+152
...@@ -0,0 +1,152 @@
1#!/usr/bin/env bash
2
3set -euo pipefail
4
5SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
7VENDOR_DIR="${MODULE_DIR}/Vendor/libghostty-vt"
8PATCH_DIR="${SCRIPT_DIR}/libghostty-android-patches"
9
10GHOSTTY_REVISION="${GHOSTTY_REVISION:-9f62873bf195e4d8a762d768a1405a5f2f7b1697}"
11GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/.cache/t3code/ghostty-${GHOSTTY_REVISION:0:8}}"
12GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}"
13GHOSTTY_ZIG="${GHOSTTY_ZIG:-}"
14ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-}"
15
16log() {
17 printf '[libghostty-vt-android] %s\n' "$*"
18}
19
20die() {
21 printf '[libghostty-vt-android] error: %s\n' "$*" >&2
22 exit 1
23}
24
25require_cmd() {
26 command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
27}
28
29ensure_zig() {
30 if [[ -n "${GHOSTTY_ZIG}" ]]; then
31 [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}"
32 return
33 fi
34 if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then
35 GHOSTTY_ZIG="$(command -v zig)"
36 return
37 fi
38
39 local host_os host_arch cache_dir
40 host_os="$(uname -s | tr '[:upper:]' '[:lower:]')"
41 host_arch="$(uname -m)"
42 case "${host_os}" in
43 darwin) host_os="macos" ;;
44 linux) ;;
45 *) die "unsupported host OS for Zig download: ${host_os}" ;;
46 esac
47 case "${host_arch}" in
48 arm64) host_arch="aarch64" ;;
49 aarch64 | x86_64) ;;
50 *) die "unsupported host architecture for Zig download: ${host_arch}" ;;
51 esac
52
53 cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}"
54 GHOSTTY_ZIG="${cache_dir}/zig"
55 if [[ -x "${GHOSTTY_ZIG}" ]]; then
56 return
57 fi
58
59 require_cmd curl
60 require_cmd tar
61 mkdir -p "${cache_dir}"
62 log "downloading Zig ${GHOSTTY_ZIG_VERSION}"
63 curl -fsSL \
64 "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \
65 | tar -xJ --strip-components=1 -C "${cache_dir}"
66}
67
68ensure_ghostty_source() {
69 if [[ ! -d "${GHOSTTY_SOURCE_DIR}/.git" ]]; then
70 require_cmd git
71 log "cloning Ghostty ${GHOSTTY_REVISION}"
72 git clone --filter=blob:none --no-checkout https://github.com/ghostty-org/ghostty.git \
73 "${GHOSTTY_SOURCE_DIR}"
74 git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}"
75 git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}"
76 fi
77
78 local actual_revision
79 actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)"
80 [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \
81 die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}"
82}
83
84apply_ghostty_patches() {
85 [[ -d "${PATCH_DIR}" ]] || return
86
87 local patch_file patch_name
88 for patch_file in "${PATCH_DIR}"/*.patch; do
89 [[ -e "${patch_file}" ]] || continue
90 patch_name="$(basename "${patch_file}")"
91 if git -C "${GHOSTTY_SOURCE_DIR}" apply --reverse --check "${patch_file}" >/dev/null 2>&1; then
92 log "patch already applied: ${patch_name}"
93 continue
94 fi
95 log "applying patch: ${patch_name}"
96 git -C "${GHOSTTY_SOURCE_DIR}" apply --check "${patch_file}"
97 git -C "${GHOSTTY_SOURCE_DIR}" apply "${patch_file}"
98 done
99}
100
101if [[ -z "${ANDROID_NDK_HOME}" ]]; then
102 die "ANDROID_NDK_HOME must point to an installed Android NDK"
103fi
104[[ -d "${ANDROID_NDK_HOME}" ]] || die "Android NDK not found: ${ANDROID_NDK_HOME}"
105
106ensure_zig
107ensure_ghostty_source
108apply_ghostty_patches
109
110strip_tool="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt"
111strip_tool="$(find "${strip_tool}" -path '*/bin/llvm-strip' -print -quit)"
112[[ -x "${strip_tool}" ]] || die "llvm-strip not found under ${ANDROID_NDK_HOME}"
113
114targets=(
115 "arm64-v8a:aarch64-linux-android"
116 "armeabi-v7a:arm-linux-androideabi"
117 "x86:x86-linux-android"
118 "x86_64:x86_64-linux-android"
119)
120
121build_root="$(mktemp -d)"
122trap 'rm -rf "${build_root}"' EXIT
123mkdir -p "${VENDOR_DIR}/include"
124
125for entry in "${targets[@]}"; do
126 abi="${entry%%:*}"
127 target="${entry#*:}"
128 prefix="${build_root}/${abi}"
129 log "building ${abi} (${target})"
130 (
131 cd "${GHOSTTY_SOURCE_DIR}"
132 ANDROID_NDK_HOME="${ANDROID_NDK_HOME}" "${GHOSTTY_ZIG}" build \
133 -Demit-lib-vt \
134 -Dtarget="${target}" \
135 -Doptimize=ReleaseFast \
136 -Dstrip=true \
137 -Dsimd=false \
138 -p "${prefix}"
139 )
140
141 mkdir -p "${MODULE_DIR}/android/src/main/jniLibs/${abi}"
142 cp "${prefix}/lib/libghostty-vt.so.0.1.0" \
143 "${MODULE_DIR}/android/src/main/jniLibs/${abi}/libghostty-vt.so"
144 "${strip_tool}" --strip-unneeded \
145 "${MODULE_DIR}/android/src/main/jniLibs/${abi}/libghostty-vt.so"
146done
147
148rm -rf "${VENDOR_DIR}/include/ghostty"
149cp -R "${build_root}/arm64-v8a/include/ghostty" "${VENDOR_DIR}/include/ghostty"
150cp "${GHOSTTY_SOURCE_DIR}/LICENSE" "${VENDOR_DIR}/LICENSE"
151printf '%s\n' "${GHOSTTY_REVISION}" > "${VENDOR_DIR}/VERSION"
152log "done"
apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0001-link-android-libvt-with-libc.patch created+25
...@@ -0,0 +1,25 @@
1diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig
2index 44c300e15..f8d4296a2 100644
3--- a/src/build/GhosttyZig.zig
4+++ b/src/build/GhosttyZig.zig
5@@ -123,7 +123,7 @@ fn initVt(
6 // no-libcxx mode (HWY_NO_LIBCXX / SIMDUTF_NO_LIBCXX) so we
7 // don't need libcpp. System-provided simdutf headers still
8 // use C++ stdlib headers, so we need libcpp in that case.
9- .link_libc = if (cfg.simd) true else null,
10+ .link_libc = if (cfg.simd or cfg.target.result.abi.isAndroid()) true else null,
11 .link_libcpp = if (cfg.simd and
12 b.systemIntegrationOption("simdutf", .{}) and
13 cfg.target.result.abi != .msvc) true else null,
14diff --git a/src/terminal/c/sys.zig b/src/terminal/c/sys.zig
15index c4b2b17f2..afb98ed4b 100644
16--- a/src/terminal/c/sys.zig
17+++ b/src/terminal/c/sys.zig
18@@ -227,6 +227,7 @@ pub fn logStderr(
19 message_len: usize,
20 ) callconv(lib.calling_conv) void {
21 if (comptime builtin.target.cpu.arch.isWasm()) return;
22+ if (comptime builtin.target.abi.isAndroid()) return;
23
24 const scope = scope_ptr[0..scope_len];
25 const message = message_ptr[0..message_len];
apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0002-align-android-libvt-to-16kb-pages.patch created+11
...@@ -0,0 +1,11 @@
1diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig
2--- a/src/build/GhosttyLibVt.zig
3+++ b/src/build/GhosttyLibVt.zig
4@@ -236,6 +236,7 @@ fn initLib(
5 if (lib.rootModuleTarget().abi.isAndroid()) {
6 // Support 16kb page sizes, required for Android 15+.
7+ lib.link_z_common_page_size = 16384; // 16kb
8 lib.link_z_max_page_size = 16384; // 16kb
9
10 try @import("android_ndk").addPaths(b, lib);
11 }
apps/mobile/package.json+6-2
...@@ -5,14 +5,14 @@...@@ -5,14 +5,14 @@
5 "main": "index.ts",5 "main": "index.ts",
6 "scripts": {6 "scripts": {
7 "dev": "expo start --clear",7 "dev": "expo start --clear",
8 "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear",8 "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --localhost",
9 "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear --lan'",9 "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear --lan'",
10 "start": "expo start",10 "start": "expo start",
11 "start:dev": "APP_VARIANT=development expo start",11 "start:dev": "APP_VARIANT=development expo start",
12 "start:preview": "APP_VARIANT=preview expo start",12 "start:preview": "APP_VARIANT=preview expo start",
13 "start:prod": "APP_VARIANT=production expo start",13 "start:prod": "APP_VARIANT=production expo start",
14 "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",14 "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
15 "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",15 "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android",
16 "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",16 "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
17 "android:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",17 "android:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
18 "eas:android:dev": "eas build --profile development -p android",18 "eas:android:dev": "eas build --profile development -p android",
...@@ -23,6 +23,7 @@...@@ -23,6 +23,7 @@
23 "ios:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",23 "ios:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
24 "ios:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",24 "ios:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
25 "ios:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",25 "ios:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
26 "ios:release": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios --configuration Release --no-bundler",
26 "eas:ios:dev": "eas build --profile development -p ios",27 "eas:ios:dev": "eas build --profile development -p ios",
27 "eas:ios:preview": "eas build --profile preview -p ios",28 "eas:ios:preview": "eas build --profile preview -p ios",
28 "eas:ios:preview:dev": "eas build --profile preview:dev -p ios",29 "eas:ios:preview:dev": "eas build --profile preview:dev -p ios",
...@@ -64,12 +65,14 @@...@@ -64,12 +65,14 @@
64 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",65 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
65 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",66 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
66 "@t3tools/shared": "workspace:*",67 "@t3tools/shared": "workspace:*",
68 "@tabler/icons-react-native": "^3.44.0",
67 "clsx": "^2.1.1",69 "clsx": "^2.1.1",
68 "diff": "8.0.3",70 "diff": "8.0.3",
69 "effect": "catalog:",71 "effect": "catalog:",
70 "expo": "~56.0.12",72 "expo": "~56.0.12",
71 "expo-asset": "~56.0.17",73 "expo-asset": "~56.0.17",
72 "expo-auth-session": "~56.0.14",74 "expo-auth-session": "~56.0.14",
75 "expo-blur": "~56.0.3",
73 "expo-build-properties": "~56.0.19",76 "expo-build-properties": "~56.0.19",
74 "expo-camera": "~56.0.8",77 "expo-camera": "~56.0.8",
75 "expo-clipboard": "~56.0.4",78 "expo-clipboard": "~56.0.4",
...@@ -86,6 +89,7 @@...@@ -86,6 +89,7 @@
86 "expo-network": "~56.0.5",89 "expo-network": "~56.0.5",
87 "expo-notifications": "~56.0.18",90 "expo-notifications": "~56.0.18",
88 "expo-paste-input": "^0.1.15",91 "expo-paste-input": "^0.1.15",
92 "expo-quick-actions": "^6.0.2",
89 "expo-secure-store": "~56.0.4",93 "expo-secure-store": "~56.0.4",
90 "expo-splash-screen": "~56.0.10",94 "expo-splash-screen": "~56.0.10",
91 "expo-sqlite": "~56.0.5",95 "expo-sqlite": "~56.0.5",
apps/mobile/plugins/withAndroidGradleHeap.cjs created+22
...@@ -0,0 +1,22 @@
1const { withGradleProperties } = require("expo/config-plugins");
2
3// The Expo template's 2GB heap is too small for D8 dex merging in this app,
4// causing OutOfMemoryError in :app:mergeExtDexDebug.
5const JVM_ARGS = "-Xmx4096m -XX:MaxMetaspaceSize=1024m";
6
7module.exports = function withAndroidGradleHeap(config) {
8 return withGradleProperties(config, (nextConfig) => {
9 const properties = nextConfig.modResults.filter(
10 (item) => !(item.type === "property" && item.key === "org.gradle.jvmargs"),
11 );
12
13 properties.push({
14 type: "property",
15 key: "org.gradle.jvmargs",
16 value: JVM_ARGS,
17 });
18
19 nextConfig.modResults = properties;
20 return nextConfig;
21 });
22};
apps/mobile/plugins/withAndroidModernAlertDialog.cjs created+188
...@@ -0,0 +1,188 @@
1const fs = require("node:fs");
2const path = require("node:path");
3const {
4 AndroidConfig,
5 withAndroidColors,
6 withAndroidColorsNight,
7 withAndroidStyles,
8 withDangerousMod,
9} = require("expo/config-plugins");
10
11// React Native's Alert renders an AppCompat AlertDialog on Android, which
12// inherits the dated framework dialog chrome (square gray panel, teal
13// all-caps buttons) from the app theme. These resources restyle it with the
14// app's uniwind tokens from global.css: --color-card panel, --color-foreground
15// text, --color-primary buttons, DM Sans type. The @font resources referenced
16// here are embedded by the expo-font plugin config in app.config.ts.
17
18// AppCompat's default dialog window background is an inset rounded rect, so
19// the replacement keeps the same 16dp inset to preserve the dialog's margins.
20const DIALOG_BACKGROUND_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
21<inset xmlns:android="http://schemas.android.com/apk/res/android"
22 android:insetLeft="16dp"
23 android:insetTop="16dp"
24 android:insetRight="16dp"
25 android:insetBottom="16dp">
26 <shape android:shape="rectangle">
27 <solid android:color="@color/alert_dialog_background" />
28 <corners android:radius="24dp" />
29 </shape>
30</inset>
31`;
32
33const COLORS = {
34 light: {
35 background: "#FFFFFF", // --color-card
36 text: "#262626", // --color-foreground
37 secondaryText: "#525252", // --color-foreground-secondary
38 buttonText: "#262626", // --color-primary
39 },
40 night: {
41 background: "#171717",
42 text: "#F5F5F5",
43 secondaryText: "#A3A3A3",
44 buttonText: "#F5F5F5",
45 },
46};
47
48function assignStyleItem(style, name, value) {
49 style.item = style.item ?? [];
50 const existing = style.item.find((item) => item.$?.name === name);
51 if (existing) {
52 existing._ = value;
53 } else {
54 style.item.push({ _: value, $: { name } });
55 }
56}
57
58function withAlertDialogStyles(config) {
59 return withAndroidStyles(config, (config) => {
60 const resources = config.modResults.resources;
61 resources.style = resources.style ?? [];
62
63 const appTheme = resources.style.find((style) => style.$?.name === "AppTheme");
64 if (appTheme) {
65 // React Native's dialog module builds an androidx.appcompat AlertDialog,
66 // which resolves its theme from the AppCompat attr; the framework attr is
67 // set too for any native code that inflates a platform AlertDialog.
68 assignStyleItem(appTheme, "alertDialogTheme", "@style/AppAlertDialog");
69 assignStyleItem(appTheme, "android:alertDialogTheme", "@style/AppAlertDialog");
70 }
71
72 resources.style = resources.style.filter(
73 (style) =>
74 !["AppAlertDialog", "AppAlertDialog.Title", "AppAlertDialog.Button"].includes(
75 style.$?.name,
76 ),
77 );
78 resources.style.push(
79 {
80 $: { name: "AppAlertDialog", parent: "ThemeOverlay.AppCompat.Dialog.Alert" },
81 item: [
82 { _: "@drawable/alert_dialog_background", $: { name: "android:windowBackground" } },
83 // The message body resolves textColorPrimary in AppCompat's alert
84 // layout; pointing it at the secondary token dims the message
85 // relative to the title, which keeps full-strength text via the
86 // explicit color in AppAlertDialog.Title.
87 { _: "@color/alert_dialog_secondary_text", $: { name: "android:textColorPrimary" } },
88 { _: "@color/alert_dialog_secondary_text", $: { name: "android:textColorSecondary" } },
89 // Theme-level fontFamily is the lowest-priority fallback in attribute
90 // resolution, so it reaches every text view in the dialog that does
91 // not carry its own fontFamily (the message body in particular).
92 { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } },
93 // AppCompat's alert title view styles itself from the framework
94 // attr (?android:attr/windowTitleStyle); there is no unprefixed
95 // AppCompat equivalent.
96 { _: "@style/AppAlertDialog.Title", $: { name: "android:windowTitleStyle" } },
97 { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarPositiveButtonStyle" } },
98 { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarNegativeButtonStyle" } },
99 { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarNeutralButtonStyle" } },
100 ],
101 },
102 {
103 $: { name: "AppAlertDialog.Title", parent: "RtlOverlay.DialogWindowTitle.AppCompat" },
104 item: [
105 { _: "@font/dm_sans_500medium", $: { name: "android:fontFamily" } },
106 { _: "18sp", $: { name: "android:textSize" } },
107 { _: "@color/alert_dialog_text", $: { name: "android:textColor" } },
108 ],
109 },
110 {
111 $: {
112 name: "AppAlertDialog.Button",
113 parent: "Widget.AppCompat.Button.ButtonBar.AlertDialog",
114 },
115 item: [
116 // The AppCompat button appearance hardcodes sans-serif-medium, so
117 // the font must be set here rather than relying on the theme
118 // fallback.
119 { _: "@font/dm_sans_500medium", $: { name: "android:fontFamily" } },
120 { _: "@color/alert_dialog_button_text", $: { name: "android:textColor" } },
121 { _: "false", $: { name: "android:textAllCaps" } },
122 { _: "0", $: { name: "android:letterSpacing" } },
123 ],
124 },
125 );
126
127 return config;
128 });
129}
130
131function assignColors(colorsResource, palette) {
132 let result = colorsResource;
133 result = AndroidConfig.Colors.assignColorValue(result, {
134 name: "alert_dialog_background",
135 value: palette.background,
136 });
137 result = AndroidConfig.Colors.assignColorValue(result, {
138 name: "alert_dialog_text",
139 value: palette.text,
140 });
141 result = AndroidConfig.Colors.assignColorValue(result, {
142 name: "alert_dialog_secondary_text",
143 value: palette.secondaryText,
144 });
145 result = AndroidConfig.Colors.assignColorValue(result, {
146 name: "alert_dialog_button_text",
147 value: palette.buttonText,
148 });
149 return result;
150}
151
152function withAlertDialogColors(config) {
153 config = withAndroidColors(config, (config) => {
154 config.modResults = assignColors(config.modResults, COLORS.light);
155 return config;
156 });
157 config = withAndroidColorsNight(config, (config) => {
158 config.modResults = assignColors(config.modResults, COLORS.night);
159 return config;
160 });
161 return config;
162}
163
164function withAlertDialogBackgroundDrawable(config) {
165 return withDangerousMod(config, [
166 "android",
167 async (config) => {
168 const drawableDir = path.join(
169 config.modRequest.platformProjectRoot,
170 "app",
171 "src",
172 "main",
173 "res",
174 "drawable",
175 );
176 fs.mkdirSync(drawableDir, { recursive: true });
177 fs.writeFileSync(
178 path.join(drawableDir, "alert_dialog_background.xml"),
179 DIALOG_BACKGROUND_DRAWABLE,
180 );
181 return config;
182 },
183 ]);
184}
185
186module.exports = function withAndroidModernAlertDialog(config) {
187 return withAlertDialogBackgroundDrawable(withAlertDialogColors(withAlertDialogStyles(config)));
188};
apps/mobile/plugins/withAndroidModernPopupMenu.cjs created+250
...@@ -0,0 +1,250 @@
1const fs = require("node:fs");
2const path = require("node:path");
3const {
4 AndroidConfig,
5 withAndroidColors,
6 withAndroidColorsNight,
7 withAndroidStyles,
8 withDangerousMod,
9} = require("expo/config-plugins");
10
11// @react-native-menu/menu renders an AppCompat PopupMenu on Android, which
12// inherits the dated default popup chrome from the app theme. These resources
13// restyle it to match the app palette (global.css --color-card / --color-foreground)
14// with rounded corners, and anchor it below the button instead of overlapping it.
15
16const POPUP_BACKGROUND_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
17<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
18 <solid android:color="@color/popup_menu_background" />
19 <corners android:radius="12dp" />
20</shape>
21`;
22
23// Checkable menu rows insert a CheckBox at the row's right edge; the theme
24// swaps its square-box button for this check glyph so the selected option
25// shows a plain right-aligned check.
26const CHECK_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
27<vector xmlns:android="http://schemas.android.com/apk/res/android"
28 android:width="18dp"
29 android:height="18dp"
30 android:viewportWidth="24"
31 android:viewportHeight="24"
32 android:tint="@color/popup_menu_item_text">
33 <path
34 android:fillColor="#FFFFFFFF"
35 android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
36</vector>
37`;
38
39// CheckBox button drawable: check glyph when selected, nothing otherwise.
40const CHECKBOX_BUTTON_SELECTOR = `<?xml version="1.0" encoding="utf-8"?>
41<selector xmlns:android="http://schemas.android.com/apk/res/android">
42 <item android:state_checked="true" android:drawable="@drawable/ic_menu_check" />
43 <item android:drawable="@android:color/transparent" />
44</selector>
45`;
46
47// Replaces the default filled-triangle submenu indicator with a stroked ">"
48// chevron.
49const SUBMENU_ARROW_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
50<vector xmlns:android="http://schemas.android.com/apk/res/android"
51 android:width="20dp"
52 android:height="20dp"
53 android:viewportWidth="24"
54 android:viewportHeight="24"
55 android:alpha="0.6"
56 android:tint="@color/popup_menu_item_text">
57 <path
58 android:pathData="M9,6l6,6l-6,6"
59 android:strokeColor="#FFFFFFFF"
60 android:strokeWidth="2"
61 android:strokeLineCap="round"
62 android:strokeLineJoin="round"
63 android:fillColor="#00000000" />
64</vector>
65`;
66
67const COLORS = {
68 light: { background: "#F7F7F7", itemText: "#262626" },
69 night: { background: "#161616", itemText: "#F5F5F5" },
70};
71
72function assignStyleItem(style, name, value) {
73 style.item = style.item ?? [];
74 const existing = style.item.find((item) => item.$?.name === name);
75 if (existing) {
76 existing._ = value;
77 } else {
78 style.item.push({ _: value, $: { name } });
79 }
80}
81
82function withPopupMenuStyles(config) {
83 return withAndroidStyles(config, (config) => {
84 const resources = config.modResults.resources;
85 resources.style = resources.style ?? [];
86
87 const appTheme = resources.style.find((style) => style.$?.name === "AppTheme");
88 if (appTheme) {
89 assignStyleItem(appTheme, "popupMenuStyle", "@style/AppPopupMenu");
90 assignStyleItem(appTheme, "android:popupMenuStyle", "@style/AppPopupMenu");
91 assignStyleItem(
92 appTheme,
93 "textAppearanceLargePopupMenu",
94 "@style/AppPopupMenu.TextAppearance",
95 );
96 assignStyleItem(
97 appTheme,
98 "textAppearanceSmallPopupMenu",
99 "@style/AppPopupMenu.TextAppearance",
100 );
101 // Submenu popups show their parent item as a header row that reads a
102 // separate theme attribute, so it needs the same themed text color.
103 assignStyleItem(
104 appTheme,
105 "textAppearancePopupMenuHeader",
106 "@style/AppPopupMenu.HeaderTextAppearance",
107 );
108 // Menu item views resolve their submenu arrow from this style
109 // (android:listMenuViewStyle / android:subMenuArrow are public attrs).
110 assignStyleItem(appTheme, "android:listMenuViewStyle", "@style/AppPopupMenuListMenuView");
111 // Checkable rows inflate a plain CheckBox at the row end; restyle its
112 // button so the selected option shows a right-aligned check glyph
113 // instead of a square box. Both framework and AppCompat attrs are set
114 // since the popup may inflate either CheckBox flavor. App-wide for
115 // native checkboxes, which the app otherwise doesn't use.
116 assignStyleItem(appTheme, "android:checkboxStyle", "@style/AppPopupMenuCheckBox");
117 assignStyleItem(appTheme, "checkboxStyle", "@style/AppPopupMenuCheckBoxCompat");
118 }
119
120 resources.style = resources.style.filter(
121 (style) =>
122 ![
123 "AppPopupMenu",
124 "AppPopupMenu.TextAppearance",
125 "AppPopupMenu.HeaderTextAppearance",
126 "AppPopupMenuListMenuView",
127 "AppPopupMenuCheckBox",
128 "AppPopupMenuCheckBoxCompat",
129 ].includes(style.$?.name),
130 );
131 resources.style.push(
132 {
133 $: { name: "AppPopupMenu", parent: "Widget.AppCompat.PopupMenu" },
134 item: [
135 { _: "@drawable/popup_menu_background", $: { name: "android:popupBackground" } },
136 { _: "false", $: { name: "android:overlapAnchor" } },
137 { _: "4dp", $: { name: "android:dropDownVerticalOffset" } },
138 ],
139 },
140 {
141 $: { name: "AppPopupMenu.TextAppearance", parent: "TextAppearance.AppCompat.Menu" },
142 item: [
143 { _: "15sp", $: { name: "android:textSize" } },
144 { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } },
145 // DM Sans (--font-sans); embedded by the expo-font plugin config in
146 // app.config.ts.
147 { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } },
148 ],
149 },
150 {
151 $: {
152 name: "AppPopupMenu.HeaderTextAppearance",
153 parent: "TextAppearance.AppCompat.Widget.PopupMenu.Header",
154 },
155 item: [
156 { _: "15sp", $: { name: "android:textSize" } },
157 { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } },
158 { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } },
159 ],
160 },
161 // The framework default (Widget.Material.ListMenuView) only carries
162 // subMenuArrow, so replacing the style wholesale is safe.
163 {
164 $: { name: "AppPopupMenuListMenuView" },
165 item: [{ _: "@drawable/popup_menu_submenu_arrow", $: { name: "android:subMenuArrow" } }],
166 },
167 {
168 $: {
169 name: "AppPopupMenuCheckBox",
170 parent: "android:Widget.Material.CompoundButton.CheckBox",
171 },
172 item: [{ _: "@drawable/popup_menu_check_button", $: { name: "android:button" } }],
173 },
174 {
175 $: {
176 name: "AppPopupMenuCheckBoxCompat",
177 parent: "Widget.AppCompat.CompoundButton.CheckBox",
178 },
179 item: [
180 { _: "@drawable/popup_menu_check_button", $: { name: "android:button" } },
181 // buttonCompat wins over android:button in AppCompatCheckBox.
182 { _: "@drawable/popup_menu_check_button", $: { name: "buttonCompat" } },
183 ],
184 },
185 );
186
187 return config;
188 });
189}
190
191function withPopupMenuColors(config) {
192 config = withAndroidColors(config, (config) => {
193 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
194 name: "popup_menu_background",
195 value: COLORS.light.background,
196 });
197 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
198 name: "popup_menu_item_text",
199 value: COLORS.light.itemText,
200 });
201 return config;
202 });
203 config = withAndroidColorsNight(config, (config) => {
204 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
205 name: "popup_menu_background",
206 value: COLORS.night.background,
207 });
208 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
209 name: "popup_menu_item_text",
210 value: COLORS.night.itemText,
211 });
212 return config;
213 });
214 return config;
215}
216
217function withPopupMenuBackgroundDrawable(config) {
218 return withDangerousMod(config, [
219 "android",
220 async (config) => {
221 const drawableDir = path.join(
222 config.modRequest.platformProjectRoot,
223 "app",
224 "src",
225 "main",
226 "res",
227 "drawable",
228 );
229 fs.mkdirSync(drawableDir, { recursive: true });
230 fs.writeFileSync(
231 path.join(drawableDir, "popup_menu_background.xml"),
232 POPUP_BACKGROUND_DRAWABLE,
233 );
234 fs.writeFileSync(path.join(drawableDir, "ic_menu_check.xml"), CHECK_DRAWABLE);
235 fs.writeFileSync(
236 path.join(drawableDir, "popup_menu_check_button.xml"),
237 CHECKBOX_BUTTON_SELECTOR,
238 );
239 fs.writeFileSync(
240 path.join(drawableDir, "popup_menu_submenu_arrow.xml"),
241 SUBMENU_ARROW_DRAWABLE,
242 );
243 return config;
244 },
245 ]);
246}
247
248module.exports = function withAndroidModernPopupMenu(config) {
249 return withPopupMenuBackgroundDrawable(withPopupMenuColors(withPopupMenuStyles(config)));
250};
apps/mobile/plugins/withAndroidPredictiveBackCompat.cjs created+104
...@@ -0,0 +1,104 @@
1const { withMainActivity } = require("expo/config-plugins");
2
3// predictiveBackGestureEnabled writes android:enableOnBackInvokedCallback="true",
4// which retires the legacy KEYCODE_BACK/onBackPressed() delivery on Android 13+.
5// From then on back gestures only reach the app through OnBackPressedDispatcher
6// callbacks. react-native 0.85 registers its own always-enabled callback, but
7// only on Android 16 with targetSdk 36 (ReactActivity's enforced-predictive-back
8// workaround) — on Android 13-15 nothing is registered, the system consumes
9// every back gesture itself, and JS back handling (React Navigation pops,
10// BackHandler listeners) silently dies: each gesture just backgrounds the app.
11// This plugin mirrors react-native's shim on API 33-35 so back keeps flowing
12// to JS there. See https://github.com/software-mansion/react-native-screens/discussions/2540.
13
14const CALLBACK_PROPERTY = `
15 // Routes predictive-back gestures to JS on Android 13-15, where react-native
16 // registers no OnBackPressedDispatcher callback of its own (it only does on
17 // Android 16+). Registered in onCreate; added by withAndroidPredictiveBackCompat.
18 private val predictiveBackCompatCallback = object : OnBackPressedCallback(true) {
19 override fun handleOnBackPressed() {
20 isEnabled = false
21 onBackPressed()
22 isEnabled = true
23 }
24 }
25`;
26
27const CALLBACK_REGISTRATION = `
28 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT < Build.VERSION_CODES.BAKLAVA) {
29 onBackPressedDispatcher.addCallback(this, predictiveBackCompatCallback)
30 }`;
31
32// Wraps the template's invokeDefaultOnBackPressed: the default action ends in
33// ComponentActivity.onBackPressed(), which re-enters the dispatcher — with the
34// compat callback still enabled that would bounce back into JS forever instead
35// of backgrounding the app.
36const INVOKE_DEFAULT_WRAPPER = `override fun invokeDefaultOnBackPressed() {
37 predictiveBackCompatCallback.isEnabled = false
38 try {
39 invokeDefaultOnBackPressedLegacy()
40 } finally {
41 predictiveBackCompatCallback.isEnabled = true
42 }
43 }
44
45 private fun invokeDefaultOnBackPressedLegacy() {`;
46
47function insertAfter(contents, anchor, insertion, description) {
48 const index = contents.indexOf(anchor);
49 if (index === -1) {
50 throw new Error(
51 `withAndroidPredictiveBackCompat: could not find ${description} in MainActivity — the Expo template changed; update the plugin anchors.`,
52 );
53 }
54 const end = index + anchor.length;
55 return contents.slice(0, end) + insertion + contents.slice(end);
56}
57
58module.exports = function withAndroidPredictiveBackCompat(config) {
59 if (config.android?.predictiveBackGestureEnabled !== true) {
60 return config;
61 }
62
63 return withMainActivity(config, (nextConfig) => {
64 let contents = nextConfig.modResults.contents;
65 if (nextConfig.modResults.language !== "kt") {
66 throw new Error("withAndroidPredictiveBackCompat: MainActivity must be Kotlin.");
67 }
68 if (contents.includes("predictiveBackCompatCallback")) {
69 return nextConfig;
70 }
71
72 contents = insertAfter(
73 contents,
74 "import android.os.Bundle",
75 "\nimport androidx.activity.OnBackPressedCallback",
76 "the android.os.Bundle import",
77 );
78 contents = insertAfter(
79 contents,
80 "class MainActivity : ReactActivity() {",
81 CALLBACK_PROPERTY,
82 "the MainActivity class declaration",
83 );
84 contents = insertAfter(
85 contents,
86 "super.onCreate(null)",
87 CALLBACK_REGISTRATION,
88 "the super.onCreate call",
89 );
90
91 if (!contents.includes("override fun invokeDefaultOnBackPressed() {")) {
92 throw new Error(
93 "withAndroidPredictiveBackCompat: could not find invokeDefaultOnBackPressed in MainActivity — the Expo template changed; update the plugin anchors.",
94 );
95 }
96 contents = contents.replace(
97 "override fun invokeDefaultOnBackPressed() {",
98 INVOKE_DEFAULT_WRAPPER,
99 );
100
101 nextConfig.modResults.contents = contents;
102 return nextConfig;
103 });
104};
apps/mobile/plugins/withoutIosPersonalTeamCapabilities.cjs created+10
...@@ -0,0 +1,10 @@
1const { withEntitlementsPlist } = require("expo/config-plugins");
2
3module.exports = function withoutIosPersonalTeamCapabilities(config) {
4 return withEntitlementsPlist(config, (modConfig) => {
5 delete modConfig.modResults["aps-environment"];
6 delete modConfig.modResults["com.apple.developer.applesignin"];
7 delete modConfig.modResults["com.apple.security.application-groups"];
8 return modConfig;
9 });
10};
apps/mobile/src/App.tsx+21-5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { BlurTargetView } from "expo-blur";
1import * as Linking from "expo-linking";2import * as Linking from "expo-linking";
2import * as SplashScreen from "expo-splash-screen";3import * as SplashScreen from "expo-splash-screen";
3import { useEffect } from "react";4import { useEffect } from "react";
...@@ -8,16 +9,24 @@ import { SafeAreaProvider } from "react-native-safe-area-context";...@@ -8,16 +9,24 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
8import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native";9import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native";
910
10import { RegistryContext } from "@effect/atom-react";11import { RegistryContext } from "@effect/atom-react";
12import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
11import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";13import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
12import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider";14import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider";
13import { RootStack } from "./Stack";15import { RootStack } from "./Stack";
14import { appAtomRegistry } from "./state/atom-registry";16import { appAtomRegistry } from "./state/atom-registry";
17import { OverlayPortalHost } from "./components/OverlayPortal";
18import { appBlurTargetRef } from "./lib/appBlurTarget";
15import { useThemeColor } from "./lib/useThemeColor";19import { useThemeColor } from "./lib/useThemeColor";
1620
17import "../global.css";21import "../global.css";
1822
19const appLinking = {23const appLinking = {
20 prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],24 prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],
25 // The Expo dev client launches the app via
26 // <scheme>://expo-development-client/?url=<packager> — that URL addresses
27 // the launcher, not app navigation. Without this filter it falls through
28 // to the NotFound wildcard route on every dev launch.
29 filter: (url: string) => !url.includes("expo-development-client"),
21};30};
2231
23const Navigation = createStaticNavigation(RootStack);32const Navigation = createStaticNavigation(RootStack);
...@@ -34,7 +43,7 @@ export default function App() {...@@ -34,7 +43,7 @@ export default function App() {
34 <RegistryContext.Provider value={appAtomRegistry}>43 <RegistryContext.Provider value={appAtomRegistry}>
35 <CloudAuthProvider>44 <CloudAuthProvider>
36 <AppearancePreferencesProvider>45 <AppearancePreferencesProvider>
37 <GestureHandlerRootView style={{ flex: 1 }}>46 <GestureHandlerRootView className="flex-1">
38 <KeyboardProvider statusBarTranslucent>47 <KeyboardProvider statusBarTranslucent>
39 <SafeAreaProvider>48 <SafeAreaProvider>
40 <StatusBar49 <StatusBar
...@@ -47,10 +56,17 @@ export default function App() {...@@ -47,10 +56,17 @@ export default function App() {
47 this, React Navigation defaults to its light theme and every native56 this, React Navigation defaults to its light theme and every native
48 header (glass buttons, title, materials) is forced light even when57 header (glass buttons, title, materials) is forced light even when
49 the system is in dark mode. */}58 the system is in dark mode. */}
50 <Navigation59 {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}
51 linking={appLinking}60 <BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}>
52 theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}61 <Navigation
53 />62 linking={appLinking}
63 theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
64 />
65 <ConfirmDialogHost />
66 </BlurTargetView>
67 {/* Anchored-menu overlays render here — in-window, so the
68 keyboard stays up while a dropdown is open. */}
69 <OverlayPortalHost />
54 </SafeAreaProvider>70 </SafeAreaProvider>
55 </KeyboardProvider>71 </KeyboardProvider>
56 </GestureHandlerRootView>72 </GestureHandlerRootView>
apps/mobile/src/Stack.tsx+35-12
...@@ -46,6 +46,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc...@@ -46,6 +46,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
46import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";46import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
47import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";47import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
48import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen";48import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen";
49import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
49import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";50import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
50import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";51import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
5152
...@@ -264,6 +265,8 @@ function RootStackLayout(props: {...@@ -264,6 +265,8 @@ function RootStackLayout(props: {
264 useThreadOutboxDrain();265 useThreadOutboxDrain();
265 // Presents the T3 Connect onboarding sheet after an in-session sign-in.266 // Presents the T3 Connect onboarding sheet after an in-session sign-in.
266 useConnectOnboardingNavigation();267 useConnectOnboardingNavigation();
268 // Launcher app shortcuts: routes shortcut taps and tracks opened threads.
269 useAppShortcuts(props.state);
267 // Full pathname (sheets included) for keyboard-command scoping; the270 // Full pathname (sheets included) for keyboard-command scoping; the
268 // workspace layout only reacts to the underlying non-overlay route.271 // workspace layout only reacts to the underlying non-overlay route.
269 const path = getPathFromState(props.state, navigationPathConfig);272 const path = getPathFromState(props.state, navigationPathConfig);
...@@ -356,9 +359,11 @@ export const RootStack = createNativeStackNavigator({...@@ -356,9 +359,11 @@ export const RootStack = createNativeStackNavigator({
356 screen: ReviewCommentComposerSheet,359 screen: ReviewCommentComposerSheet,
357 linking: `${THREAD_LINKING_PREFIX}/review-comment`,360 linking: `${THREAD_LINKING_PREFIX}/review-comment`,
358 options: {361 options: {
359 presentation: "formSheet",362 // Android cannot host the keyboard-driven comment composer inside a
360 sheetAllowedDetents: [0.55, 0.92],363 // formSheet; use a full-screen modal there instead.
361 sheetGrabberVisible: true,364 presentation: Platform.OS === "android" ? "fullScreenModal" : "formSheet",
365 sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92],
366 sheetGrabberVisible: Platform.OS !== "android",
362 },367 },
363 }),368 }),
364 ThreadFiles: createNativeStackScreen({369 ThreadFiles: createNativeStackScreen({
...@@ -420,9 +425,15 @@ export const RootStack = createNativeStackNavigator({...@@ -420,9 +425,15 @@ export const RootStack = createNativeStackNavigator({
420 options: {425 options: {
421 gestureEnabled: true,426 gestureEnabled: true,
422 headerShown: false,427 headerShown: false,
423 presentation: "formSheet",428 // Android pushes settings as a regular full page with an in-screen
424 sheetAllowedDetents: [0.7, 0.92],429 // back header; iOS keeps the detented form sheet.
425 sheetGrabberVisible: true,430 ...(Platform.OS === "android"
431 ? { presentation: "card" as const }
432 : {
433 presentation: "formSheet" as const,
434 sheetAllowedDetents: [0.7, 0.92],
435 sheetGrabberVisible: true,
436 }),
426 },437 },
427 }),438 }),
428 ConnectOnboarding: createNativeStackScreen({439 ConnectOnboarding: createNativeStackScreen({
...@@ -444,9 +455,15 @@ export const RootStack = createNativeStackNavigator({...@@ -444,9 +455,15 @@ export const RootStack = createNativeStackNavigator({
444 linking: "connections",455 linking: "connections",
445 options: {456 options: {
446 title: "Environments",457 title: "Environments",
447 presentation: "formSheet",458 // Android: full page; the screen renders its own AndroidScreenHeader,
448 sheetAllowedDetents: [0.55, 0.7],459 // so the native bar stays hidden. iOS keeps the sheet.
449 sheetGrabberVisible: true,460 ...(Platform.OS === "android"
461 ? { presentation: "card" as const, headerShown: false }
462 : {
463 presentation: "formSheet" as const,
464 sheetAllowedDetents: [0.55, 0.7],
465 sheetGrabberVisible: true,
466 }),
450 },467 },
451 }),468 }),
452 ConnectionsNew: createNativeStackScreen({469 ConnectionsNew: createNativeStackScreen({
...@@ -468,9 +485,15 @@ export const RootStack = createNativeStackNavigator({...@@ -468,9 +485,15 @@ export const RootStack = createNativeStackNavigator({
468 options: {485 options: {
469 gestureEnabled: true,486 gestureEnabled: true,
470 headerShown: false,487 headerShown: false,
471 presentation: "formSheet",488 // Android pushes the flow as a regular full page — the draft should
472 sheetAllowedDetents: [0.92],489 // read like a thread that just doesn't exist yet; iOS keeps the sheet.
473 sheetGrabberVisible: true,490 ...(Platform.OS === "android"
491 ? { presentation: "card" as const }
492 : {
493 presentation: "formSheet" as const,
494 sheetAllowedDetents: [0.92],
495 sheetGrabberVisible: true,
496 }),
474 },497 },
475 }),498 }),
476 NotFound: createNativeStackScreen({499 NotFound: createNativeStackScreen({
apps/mobile/src/components/AndroidAnchoredMenu.tsx created+337
...@@ -0,0 +1,337 @@
1import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu";
2import { BlurView } from "expo-blur";
3import type { ReactNode } from "react";
4import { useCallback, useEffect, useRef, useState } from "react";
5import type { StyleProp, ViewStyle } from "react-native";
6import { BackHandler, Pressable, ScrollView, useColorScheme, View } from "react-native";
7import { useKeyboardState } from "react-native-keyboard-controller";
8import Animated, { FadeIn } from "react-native-reanimated";
9
10import { appBlurTargetRef } from "../lib/appBlurTarget";
11import { useThemeColor } from "../lib/useThemeColor";
12import { cn } from "../lib/cn";
13import { type AppSymbolName, SymbolView } from "./AppSymbol";
14import { AppText as Text } from "./AppText";
15import { OverlayPortal } from "./OverlayPortal";
16
17const MENU_WIDTH = 250;
18const SCREEN_MARGIN = 12;
19const ANCHOR_GAP = 6;
20
21// Anchor position is snapshotted in window coordinates when the menu opens;
22// the overlay root measures itself the same way, and the menu is placed from
23// the delta. Both snapshots are taken at open time so later reflows (keyboard
24// show/hide, screen transitions) can't flip an opens-up menu to opens-down
25// mid-presentation.
26type AnchorSnapshot = {
27 readonly x: number;
28 readonly y: number;
29 readonly width: number;
30 readonly height: number;
31};
32
33type OverlayFrame = {
34 readonly x: number;
35 readonly y: number;
36 readonly width: number;
37 readonly height: number;
38};
39
40export type AndroidAnchoredMenuProps = {
41 readonly actions: readonly MenuAction[];
42 readonly title?: string;
43 readonly onPressAction?: MenuComponentProps["onPressAction"];
44 /** Applied to the anchor wrapper — call sites flex these to fill toolbars. */
45 readonly className?: string;
46 readonly style?: StyleProp<ViewStyle>;
47 /**
48 * Plain children open the menu on tap (the wrapper owns the press). A
49 * render function keeps the children interactive and hands them `open` to
50 * call from their own gesture — e.g. a row that selects on tap and opens
51 * this menu on long-press.
52 */
53 readonly children: ReactNode | ((open: () => void) => ReactNode);
54};
55
56/**
57 * Token-styled anchored dropdown for Android, drop-in for the subset of the
58 * MenuView contract the app uses (actions with state/subtitle/image/
59 * attributes, one level of subactions). The native AppCompat PopupMenu caps
60 * out on theming — stock animation, item metrics, and submenu chrome — so
61 * ControlPillMenu renders this instead on Android while iOS keeps the native
62 * UIMenu. Styling follows the themed native popup (12dp radius, plain rows,
63 * trailing check glyph); submenus drill in under a muted parent-title header.
64 */
65export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
66 const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);
67 const [path, setPath] = useState<readonly MenuAction[]>([]);
68 // Height of the modal's root view, in the modal's own coordinate space.
69 // Menus that flip above their anchor are pinned by their BOTTOM edge
70 // (bottom = rootHeight - anchorTop), so drill-in height changes grow
71 // upward without any re-measurement — positioning them via `top` from the
72 // menu's measured height made every submenu transition settle over two
73 // frames and jitter.
74 const [rootHeight, setRootHeight] = useState<number | null>(null);
75 // Window frame of the overlay root, measured on layout. Anchor coordinates
76 // are converted into this frame, so the menu lands correctly no matter
77 // where the portal host sits (status bar, keyboard resize, etc.).
78 const [overlay, setOverlay] = useState<OverlayFrame | null>(null);
79 const anchorRef = useRef<View>(null);
80 const overlayRef = useRef<View>(null);
81
82 const isDarkMode = useColorScheme() === "dark";
83 const keyboardVisible = useKeyboardState((state) => state.isVisible);
84 const keyboardHeight = useKeyboardState((state) => state.height);
85 const rippleColor = useThemeColor("--color-subtle");
86 const iconColor = useThemeColor("--color-icon");
87 const iconSubtleColor = useThemeColor("--color-icon-subtle");
88 const dangerColor = useThemeColor("--color-danger-foreground");
89
90 const close = useCallback(() => {
91 setAnchor(null);
92 setPath([]);
93 setOverlay(null);
94 setRootHeight(null);
95 }, []);
96
97 const open = useCallback(() => {
98 anchorRef.current?.measureInWindow((x, y, width, height) => {
99 setAnchor({ x, y, width, height });
100 });
101 }, []);
102
103 const measureOverlay = useCallback(() => {
104 overlayRef.current?.measureInWindow((x, y, width, height) => {
105 setOverlay({ x, y, width, height });
106 setRootHeight(height);
107 });
108 }, []);
109
110 // The dropdown renders in-window (no Modal takes focus), so the hardware
111 // back gesture needs explicit handling while it is open. Back steps out of
112 // a drilled-in submenu one level at a time (mirroring the tappable parent
113 // header) before closing the menu. Under predictive back
114 // (enableOnBackInvokedCallback) this stays correct: back reaches JS
115 // through always-registered OnBackPressedDispatcher callbacks (react-native
116 // core on Android 16+, withAndroidPredictiveBackCompat on 13-15), which
117 // also keeps the system from playing a "leave app" preview while the menu
118 // merely closes.
119 const submenuDepth = path.length;
120 useEffect(() => {
121 if (anchor === null) {
122 return;
123 }
124 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
125 if (submenuDepth > 0) {
126 setPath((current) => current.slice(0, -1));
127 } else {
128 close();
129 }
130 return true;
131 });
132 return () => subscription.remove();
133 }, [anchor, close, submenuDepth]);
134
135 const parent = path.length > 0 ? path[path.length - 1] : null;
136 const levelActions = (parent?.subactions ?? props.actions).filter(
137 (action) => !(action.attributes?.hidden ?? false),
138 );
139
140 // Anchor in overlay-local coordinates (both measured in window space).
141 const local =
142 anchor === null || overlay === null
143 ? null
144 : {
145 x: anchor.x - overlay.x,
146 y: anchor.y - overlay.y,
147 width: anchor.width,
148 height: anchor.height,
149 };
150 const preferredLeft =
151 local === null || overlay === null
152 ? 0
153 : local.x + local.width / 2 <= overlay.width / 2
154 ? local.x
155 : local.x + local.width - MENU_WIDTH;
156 const left =
157 overlay === null
158 ? 0
159 : Math.min(
160 Math.max(preferredLeft, SCREEN_MARGIN),
161 overlay.width - MENU_WIDTH - SCREEN_MARGIN,
162 );
163 // The keyboard stays up while the menu is open (in-window overlay, no
164 // focus change), so the space it covers is not usable — without this the
165 // composer-pill menus "open down" into the IME and can't be tapped.
166 const usableBottom =
167 overlay === null ? 0 : overlay.height - (keyboardVisible ? keyboardHeight : 0);
168 const spaceBelow =
169 local === null || overlay === null
170 ? 0
171 : usableBottom - (local.y + local.height) - ANCHOR_GAP - SCREEN_MARGIN;
172 const spaceAbove = local === null ? 0 : local.y - ANCHOR_GAP - SCREEN_MARGIN;
173 const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove;
174 const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480);
175 // The menu needs the overlay frame before it can be placed; it stays
176 // unmounted for that first frame so the fade-in plays at the final position.
177 const placeable = local !== null && rootHeight !== null;
178
179 const onPressItem = useCallback(
180 (action: MenuAction) => {
181 if ((action.subactions?.length ?? 0) > 0) {
182 setPath((current) => [...current, action]);
183 return;
184 }
185 close();
186 if (action.id !== undefined) {
187 props.onPressAction?.({
188 nativeEvent: { event: action.id },
189 } as Parameters<NonNullable<MenuComponentProps["onPressAction"]>>[0]);
190 }
191 },
192 [close, props.onPressAction],
193 );
194
195 return (
196 <>
197 {typeof props.children === "function" ? (
198 <View ref={anchorRef} collapsable={false} className={props.className} style={props.style}>
199 {props.children(open)}
200 </View>
201 ) : (
202 <Pressable
203 ref={anchorRef}
204 accessibilityRole="button"
205 className={props.className}
206 collapsable={false}
207 style={props.style}
208 onPress={open}
209 >
210 <View pointerEvents="none">{props.children}</View>
211 </Pressable>
212 )}
213 {anchor === null ? null : (
214 <OverlayPortal>
215 <View
216 ref={overlayRef}
217 collapsable={false}
218 className="absolute inset-0"
219 onLayout={measureOverlay}
220 >
221 <Pressable accessible={false} className="absolute inset-0" onPress={close} />
222 {!placeable || local === null ? null : (
223 <Animated.View
224 entering={FadeIn.duration(120)}
225 className="absolute w-[250px] overflow-hidden rounded-[12px] border border-border shadow-2xl"
226 style={{
227 left,
228 maxHeight,
229 ...(opensDown
230 ? { top: local.y + local.height + ANCHOR_GAP }
231 : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }),
232 }}
233 >
234 {/* Frosted backdrop: blur of the app content behind the menu,
235 washed with the translucent card tone so rows keep contrast. */}
236 <BlurView
237 blurMethod="dimezisBlurView"
238 blurTarget={appBlurTargetRef}
239 intensity={40}
240 tint={isDarkMode ? "dark" : "light"}
241 className="absolute inset-0"
242 />
243 <View className="absolute inset-0 bg-card-translucent" />
244 {/* keyboardShouldPersistTaps: the menu often opens over an
245 active editor; the first item tap must act, not just
246 dismiss the keyboard. */}
247 <ScrollView
248 bounces={false}
249 keyboardShouldPersistTaps="always"
250 showsVerticalScrollIndicator={false}
251 >
252 {parent !== null ? (
253 // Muted parent title as the submenu header; tapping it
254 // steps back, but it reads as a label, not a button.
255 <Pressable
256 className="px-3.5 pb-1 pt-2.5"
257 onPress={() => setPath((current) => current.slice(0, -1))}
258 >
259 <Text className="text-xs font-t3-bold text-foreground-muted">
260 {parent.title}
261 </Text>
262 </Pressable>
263 ) : props.title ? (
264 <>
265 <View className="px-3.5 py-2">
266 <Text className="text-center text-xs text-foreground-muted">
267 {props.title}
268 </Text>
269 </View>
270 <View className="h-px bg-border" />
271 </>
272 ) : null}
273 {levelActions.map((action, index) => {
274 const destructive = action.attributes?.destructive ?? false;
275 const disabled = action.attributes?.disabled ?? false;
276 const hasSubmenu = (action.subactions?.length ?? 0) > 0;
277 return (
278 <Pressable
279 key={action.id ?? `${index}-${action.title}`}
280 android_ripple={{ color: rippleColor }}
281 disabled={disabled}
282 className={cn(
283 "min-h-11 flex-row items-center gap-2.5 px-3.5 py-2.5",
284 disabled && "opacity-45",
285 )}
286 onPress={() => onPressItem(action)}
287 >
288 <View className="flex-1 gap-0.5">
289 <Text
290 className={cn(
291 // Same face as the pill labels that open these menus.
292 "text-sm font-t3-bold",
293 destructive && "text-danger-foreground",
294 )}
295 >
296 {action.title}
297 </Text>
298 {action.subtitle ? (
299 <Text className="text-xs leading-snug text-foreground-muted">
300 {action.subtitle}
301 </Text>
302 ) : null}
303 </View>
304 {hasSubmenu ? (
305 <SymbolView
306 name="chevron.right"
307 size={13}
308 tintColor={iconSubtleColor}
309 type="monochrome"
310 />
311 ) : action.state === "on" ? (
312 <SymbolView
313 name="checkmark"
314 size={15}
315 tintColor={iconColor}
316 type="monochrome"
317 />
318 ) : action.image ? (
319 <SymbolView
320 name={action.image as AppSymbolName}
321 size={15}
322 tintColor={destructive ? dangerColor : iconColor}
323 type="monochrome"
324 />
325 ) : null}
326 </Pressable>
327 );
328 })}
329 </ScrollView>
330 </Animated.View>
331 )}
332 </View>
333 </OverlayPortal>
334 )}
335 </>
336 );
337}
apps/mobile/src/components/AndroidScreenHeader.tsx created+110
...@@ -0,0 +1,110 @@
1import type { ReactNode } from "react";
2import { Pressable, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
4
5import { SymbolView, type AppSymbolName } from "./AppSymbol";
6import { AppText as Text } from "./AppText";
7import { cn } from "../lib/cn";
8import { useThemeColor } from "../lib/useThemeColor";
9
10export interface AndroidHeaderAction {
11 readonly accessibilityLabel: string;
12 readonly icon: AppSymbolName;
13 readonly onPress: () => void;
14 readonly disabled?: boolean;
15}
16
17export function AndroidHeaderIconButton(props: {
18 readonly accessibilityLabel: string;
19 readonly icon: AppSymbolName;
20 readonly onPress?: () => void;
21 readonly disabled?: boolean;
22}) {
23 const foregroundColor = useThemeColor("--color-foreground");
24 const disabledColor = useThemeColor("--color-icon-subtle");
25
26 return (
27 <Pressable
28 accessibilityLabel={props.accessibilityLabel}
29 accessibilityRole="button"
30 disabled={props.disabled}
31 hitSlop={8}
32 onPress={props.onPress}
33 className={cn(
34 "size-11 items-center justify-center rounded-full bg-subtle",
35 props.disabled && "opacity-55",
36 )}
37 >
38 <SymbolView
39 name={props.icon}
40 size={20}
41 tintColor={props.disabled ? disabledColor : foregroundColor}
42 type="monochrome"
43 />
44 </Pressable>
45 );
46}
47
48export function AndroidScreenHeader(props: {
49 readonly title: string;
50 readonly subtitle?: string | null;
51 readonly actions?: ReadonlyArray<AndroidHeaderAction>;
52 readonly trailing?: ReactNode;
53 readonly onBack?: () => void;
54}) {
55 const insets = useSafeAreaInsets();
56 const foregroundColor = useThemeColor("--color-foreground");
57
58 return (
59 <View
60 className="border-b border-header-border bg-header px-3 pb-2.5"
61 style={{
62 paddingTop: Math.max(insets.top, 12),
63 }}
64 >
65 <View className="min-h-12 flex-row items-center gap-2">
66 {props.onBack ? (
67 <Pressable
68 accessibilityLabel="Navigate up"
69 accessibilityRole="button"
70 hitSlop={8}
71 onPress={props.onBack}
72 className="size-11 items-center justify-center"
73 >
74 <SymbolView
75 name="chevron.left"
76 size={24}
77 tintColor={foregroundColor}
78 type="monochrome"
79 />
80 </Pressable>
81 ) : null}
82
83 <View className={cn("min-w-0 flex-1", !props.onBack && "pl-1")}>
84 <Text numberOfLines={1} className="text-lg font-t3-bold text-foreground">
85 {props.title}
86 </Text>
87 {props.subtitle ? (
88 <Text
89 numberOfLines={1}
90 className="mt-px text-[13px] font-t3-medium text-foreground-muted"
91 >
92 {props.subtitle}
93 </Text>
94 ) : null}
95 </View>
96
97 {props.actions?.map((action) => (
98 <AndroidHeaderIconButton
99 key={action.accessibilityLabel}
100 accessibilityLabel={action.accessibilityLabel}
101 disabled={action.disabled}
102 icon={action.icon}
103 onPress={action.onPress}
104 />
105 ))}
106 {props.trailing}
107 </View>
108 </View>
109 );
110}
apps/mobile/src/components/AppSymbol.tsx created+202
...@@ -0,0 +1,202 @@
1import {
2 IconAdjustmentsHorizontal,
3 IconAlertCircle,
4 IconAlertTriangle,
5 IconArchive,
6 IconArrowBackUp,
7 IconArrowDownCircle,
8 IconArrowRightCircle,
9 IconArrowUp,
10 IconArrowUpCircle,
11 IconArrowUpRight,
12 IconArrowUpRightCircle,
13 IconArrowsMaximize,
14 IconBellRinging,
15 IconBolt,
16 IconCamera,
17 IconCheck,
18 IconChevronDown,
19 IconCode,
20 IconChevronLeft,
21 IconChevronRight,
22 IconChevronUp,
23 IconCircleCheck,
24 IconCircleXFilled,
25 IconCopy,
26 IconDeviceDesktop,
27 IconDots,
28 IconDotsCircleHorizontal,
29 IconEdit,
30 IconExternalLink,
31 IconEye,
32 IconFileText,
33 IconFilter,
34 IconFolder,
35 IconFolderOpen,
36 IconFolderPlus,
37 IconGitBranch,
38 IconHammer,
39 IconGitMerge,
40 IconGitPullRequest,
41 IconInfoCircle,
42 IconKeyboard,
43 IconKeyboardHide,
44 IconLayoutColumns,
45 IconLayoutSidebar,
46 IconLetterSpacing,
47 IconLink,
48 IconMessage,
49 IconMinus,
50 IconNetwork,
51 IconPalette,
52 IconPlayerPlay,
53 IconPlayerStopFilled,
54 IconPlus,
55 IconQrcode,
56 IconRefresh,
57 IconSearch,
58 IconServer,
59 IconSettings,
60 IconSparkles,
61 IconLayoutSidebarRight,
62 IconTerminal2,
63 IconTextDecrease,
64 IconTextIncrease,
65 IconTool,
66 IconTrash,
67 IconTypography,
68 IconUserCircle,
69 IconWifiOff,
70 IconWorld,
71 IconX,
72 type Icon,
73} from "@tabler/icons-react-native";
74import { Platform } from "react-native";
75import { SymbolView as ExpoSymbolView, type SFSymbol, type SymbolViewProps } from "expo-symbols";
76
77const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
78 "arrow.branch": IconGitBranch,
79 "arrow.clockwise": IconRefresh,
80 "arrow.down.circle": IconArrowDownCircle,
81 "arrow.right.circle": IconArrowRightCircle,
82 "arrow.triangle.branch": IconGitBranch,
83 "arrow.triangle.pull": IconGitPullRequest,
84 "arrow.turn.left.up": IconArrowBackUp,
85 "arrow.up": IconArrowUp,
86 "arrow.up.circle": IconArrowUpCircle,
87 "arrow.up.left.and.arrow.down.right": IconArrowsMaximize,
88 "arrow.up.right": IconArrowUpRight,
89 "arrow.up.right.circle": IconArrowUpRightCircle,
90 "arrow.uturn.backward": IconArrowBackUp,
91 archivebox: IconArchive,
92 "archivebox.fill": IconArchive,
93 "bell.badge": IconBellRinging,
94 "bolt.circle": IconBolt,
95 "bolt.horizontal.circle": IconBolt,
96 camera: IconCamera,
97 checkmark: IconCheck,
98 "checkmark.circle": IconCircleCheck,
99 "chevron.down": IconChevronDown,
100 "chevron.left": IconChevronLeft,
101 "chevron.left.forwardslash.chevron.right": IconCode,
102 "chevron.right": IconChevronRight,
103 "chevron.up": IconChevronUp,
104 desktopcomputer: IconDeviceDesktop,
105 "doc.on.doc": IconCopy,
106 "doc.text": IconFileText,
107 ellipsis: IconDots,
108 "ellipsis.circle": IconDotsCircleHorizontal,
109 "exclamationmark.triangle": IconAlertTriangle,
110 eye: IconEye,
111 folder: IconFolder,
112 "folder.badge.plus": IconFolderPlus,
113 "folder.fill": IconFolder,
114 gearshape: IconSettings,
115 "info.circle": IconInfoCircle,
116 link: IconLink,
117 "line.3.horizontal.decrease.circle": IconFilter,
118 "line.3.horizontal.decrease.circle.fill": IconFilter,
119 magnifyingglass: IconSearch,
120 paintbrush: IconPalette,
121 "person.crop.circle": IconUserCircle,
122 play: IconPlayerPlay,
123 plus: IconPlus,
124 "qrcode.viewfinder": IconQrcode,
125 "point.3.connected.trianglepath.dotted": IconNetwork,
126 "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge,
127 safari: IconExternalLink,
128 "server.rack": IconServer,
129 "sidebar.left": IconLayoutSidebar,
130 "sidebar.right": IconLayoutSidebarRight,
131 "slider.horizontal.3": IconAdjustmentsHorizontal,
132 "square.and.pencil": IconEdit,
133 "square.split.2x1": IconLayoutColumns,
134 "stop.fill": IconPlayerStopFilled,
135 terminal: IconTerminal2,
136 "text.bubble": IconMessage,
137 "text.word.spacing": IconLetterSpacing,
138 "textformat.size": IconTypography,
139 "textformat.size.larger": IconTextIncrease,
140 "textformat.size.smaller": IconTextDecrease,
141 trash: IconTrash,
142 "wifi.slash": IconWifiOff,
143 xmark: IconX,
144 "xmark.circle.fill": IconCircleXFilled,
145};
146
147// Callers can pass `{ ios, android }` names where `android` is a Material
148// icon name (the raw expo-symbols contract). Resolve those here too so the
149// android key keeps working through this wrapper — it wins over the SF map
150// when both match (e.g. folder vs folder_open for expanded project groups).
151const ANDROID_ICON_BY_MATERIAL_NAME: Record<string, Icon> = {
152 auto_awesome: IconSparkles,
153 bolt: IconBolt,
154 build: IconTool,
155 chat_bubble: IconMessage,
156 check: IconCheck,
157 close: IconX,
158 construction: IconHammer,
159 content_copy: IconCopy,
160 edit: IconEdit,
161 error: IconAlertCircle,
162 folder: IconFolder,
163 folder_open: IconFolderOpen,
164 keyboard: IconKeyboard,
165 keyboard_arrow_down: IconChevronDown,
166 keyboard_arrow_up: IconChevronUp,
167 keyboard_hide: IconKeyboardHide,
168 public: IconWorld,
169 remove: IconMinus,
170 terminal: IconTerminal2,
171 visibility: IconEye,
172};
173
174export type { SFSymbol } from "expo-symbols";
175export type AppSymbolName = SymbolViewProps["name"];
176
177export function SymbolView(props: SymbolViewProps) {
178 if (Platform.OS !== "android") {
179 return <ExpoSymbolView {...props} />;
180 }
181
182 const materialName = typeof props.name === "string" ? undefined : props.name.android;
183 const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios;
184 const AndroidIcon =
185 (materialName ? ANDROID_ICON_BY_MATERIAL_NAME[materialName] : undefined) ??
186 (sfSymbol ? ANDROID_ICON_BY_SF_SYMBOL[sfSymbol] : undefined);
187
188 if (!AndroidIcon) {
189 return props.fallback ?? null;
190 }
191
192 return (
193 <AndroidIcon
194 accessibilityLabel={props.accessibilityLabel}
195 color={props.tintColor}
196 size={props.size}
197 strokeWidth={2}
198 style={props.style}
199 testID={props.testID}
200 />
201 );
202}
apps/mobile/src/components/ComposerAttachmentStrip.tsx+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../components/AppSymbol";
2import { Image, Pressable, ScrollView, View } from "react-native";2import { Image, Pressable, ScrollView, View } from "react-native";
3import { useThemeColor } from "../lib/useThemeColor";3import { useThemeColor } from "../lib/useThemeColor";
44
apps/mobile/src/components/ComposerToolbarTrigger.tsx+9-3
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { SymbolView } from "expo-symbols";
2import type { ComponentProps, ReactNode } from "react";1import type { ComponentProps, ReactNode } from "react";
3import { useCallback, useMemo, useState } from "react";2import { useCallback, useMemo, useState } from "react";
4import {3import {
...@@ -16,6 +15,7 @@ import {...@@ -16,6 +15,7 @@ import {
16import { useThemeColor } from "../lib/useThemeColor";15import { useThemeColor } from "../lib/useThemeColor";
17import { cn } from "../lib/cn";16import { cn } from "../lib/cn";
18import { AppText as Text } from "./AppText";17import { AppText as Text } from "./AppText";
18import { SymbolView } from "./AppSymbol";
1919
20export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44;20export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44;
21export const COMPOSER_TOOLBAR_GAP = 8;21export const COMPOSER_TOOLBAR_GAP = 8;
...@@ -148,6 +148,7 @@ export function ComposerToolbarButton(props: {...@@ -148,6 +148,7 @@ export function ComposerToolbarButton(props: {
148 readonly showChevron?: boolean;148 readonly showChevron?: boolean;
149 readonly textTransform?: "none" | "uppercase";149 readonly textTransform?: "none" | "uppercase";
150 readonly variant?: "default" | "primary" | "danger";150 readonly variant?: "default" | "primary" | "danger";
151 readonly className?: string;
151 readonly style?: StyleProp<ViewStyle>;152 readonly style?: StyleProp<ViewStyle>;
152}) {153}) {
153 const isDarkMode = useColorScheme() === "dark";154 const isDarkMode = useColorScheme() === "dark";
...@@ -181,7 +182,11 @@ export function ComposerToolbarButton(props: {...@@ -181,7 +182,11 @@ export function ComposerToolbarButton(props: {
181 disabled={props.disabled}182 disabled={props.disabled}
182 onPress={props.onPress}183 onPress={props.onPress}
183 className={cn(184 className={cn(
184 "h-11 flex-row items-center justify-center rounded-full active:opacity-70",185 // Default width cap lives in the class chain (not the inline style)
186 // so callers can lift it with max-w-full — flex-filling pills in the
187 // thread composer stretch to the row's edge. The numeric maxWidth
188 // prop still wins via the inline style below.
189 "h-11 max-w-[172px] flex-row items-center justify-center rounded-full active:opacity-70",
185 isCircle ? "w-11" : "gap-2 px-3.5",190 isCircle ? "w-11" : "gap-2 px-3.5",
186 variant === "primary"191 variant === "primary"
187 ? props.disabled192 ? props.disabled
...@@ -192,6 +197,7 @@ export function ComposerToolbarButton(props: {...@@ -192,6 +197,7 @@ export function ComposerToolbarButton(props: {
192 : props.active197 : props.active
193 ? "bg-subtle-strong"198 ? "bg-subtle-strong"
194 : "bg-subtle",199 : "bg-subtle",
200 props.className,
195 )}201 )}
196 style={({ pressed }) => [202 style={({ pressed }) => [
197 {203 {
...@@ -202,7 +208,7 @@ export function ComposerToolbarButton(props: {...@@ -202,7 +208,7 @@ export function ComposerToolbarButton(props: {
202 : defaultBorderColor208 : defaultBorderColor
203 : filledBorderColor,209 : filledBorderColor,
204 borderWidth: 1,210 borderWidth: 1,
205 maxWidth: props.maxWidth ?? 172,211 maxWidth: props.maxWidth,
206 minWidth: props.minWidth,212 minWidth: props.minWidth,
207 opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1,213 opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1,
208 shadowColor: "#000",214 shadowColor: "#000",
apps/mobile/src/components/ConfirmDialogHost.tsx created+111
...@@ -0,0 +1,111 @@
1import { useCallback, useEffect, useState } from "react";
2import { Modal, Pressable, View } from "react-native";
3
4import { useThemeColor } from "../lib/useThemeColor";
5import { cn } from "../lib/cn";
6import { AppText } from "./AppText";
7
8export type ConfirmDialogRequest = {
9 readonly title: string;
10 readonly message?: string;
11 readonly cancelText?: string;
12 readonly confirmText: string;
13 readonly destructive?: boolean;
14 readonly onConfirm: () => void;
15 readonly onCancel?: () => void;
16};
17
18let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;
19
20/**
21 * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already
22 * match the app (and support per-button destructive red), so this is for
23 * Android, where the native dialog can only theme all confirm buttons at
24 * once. Requires ConfirmDialogHost to be mounted at the app root.
25 */
26export function showConfirmDialog(request: ConfirmDialogRequest): void {
27 presentRequest?.(request);
28}
29
30/**
31 * Android-style alert dialog matching the native one themed by
32 * withAndroidModernAlertDialog — left-aligned text, right-aligned text
33 * buttons — with what the native theme can't do: a per-dialog destructive
34 * button color and a dimmer message than the title.
35 */
36export function ConfirmDialogHost() {
37 const [request, setRequest] = useState<ConfirmDialogRequest | null>(null);
38 const pressedOverlay = useThemeColor("--color-subtle");
39
40 useEffect(() => {
41 presentRequest = setRequest;
42 return () => {
43 presentRequest = null;
44 };
45 }, []);
46
47 const handleCancel = useCallback(() => {
48 request?.onCancel?.();
49 setRequest(null);
50 }, [request]);
51
52 const handleConfirm = useCallback(() => {
53 request?.onConfirm();
54 setRequest(null);
55 }, [request]);
56
57 return (
58 <Modal
59 visible={request !== null}
60 transparent
61 animationType="fade"
62 statusBarTranslucent
63 navigationBarTranslucent
64 onRequestClose={handleCancel}
65 >
66 {request === null ? null : (
67 <View className="flex-1 items-center justify-center bg-backdrop px-8">
68 <View className="w-full rounded-[24px] bg-card px-6 pb-4 pt-5">
69 <AppText className="text-lg font-t3-medium">{request.title}</AppText>
70 {request.message === undefined ? null : (
71 <AppText className="mt-2 text-sm text-foreground-secondary">
72 {request.message}
73 </AppText>
74 )}
75 <View className="mt-5 flex-row justify-end gap-1">
76 <View className="overflow-hidden rounded-full">
77 <Pressable
78 accessibilityRole="button"
79 className="min-h-10 items-center justify-center px-4"
80 android_ripple={{ color: pressedOverlay }}
81 onPress={handleCancel}
82 >
83 <AppText className="text-base font-t3-medium">
84 {request.cancelText ?? "Cancel"}
85 </AppText>
86 </Pressable>
87 </View>
88 <View className="overflow-hidden rounded-full">
89 <Pressable
90 accessibilityRole="button"
91 className="min-h-10 items-center justify-center px-4"
92 android_ripple={{ color: pressedOverlay }}
93 onPress={handleConfirm}
94 >
95 <AppText
96 className={cn(
97 "text-base font-t3-medium",
98 request.destructive && "text-danger-foreground",
99 )}
100 >
101 {request.confirmText}
102 </AppText>
103 </Pressable>
104 </View>
105 </View>
106 </View>
107 </View>
108 )}
109 </Modal>
110 );
111}
apps/mobile/src/components/ControlPill.tsx+57-5
...@@ -1,10 +1,18 @@...@@ -1,10 +1,18 @@
1import { MenuView } from "@react-native-menu/menu";1import { MenuView } from "@react-native-menu/menu";
2import type { ComponentProps, ReactNode } from "react";2import * as Haptics from "expo-haptics";
3import { Pressable, useColorScheme, View } from "react-native";3import {
4import { SymbolView } from "expo-symbols";4 cloneElement,
5 isValidElement,
6 type ComponentProps,
7 type ReactElement,
8 type ReactNode,
9} from "react";
10import { Platform, Pressable, useColorScheme, View } from "react-native";
5import { useThemeColor } from "../lib/useThemeColor";11import { useThemeColor } from "../lib/useThemeColor";
612
7import { cn } from "../lib/cn";13import { cn } from "../lib/cn";
14import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
15import { SymbolView } from "./AppSymbol";
8import { AppText as Text } from "./AppText";16import { AppText as Text } from "./AppText";
917
10export function ControlPill(props: {18export function ControlPill(props: {
...@@ -74,16 +82,60 @@ export function ControlPill(props: {...@@ -74,16 +82,60 @@ export function ControlPill(props: {
74 );82 );
75}83}
7684
85// iOS renders the native UIMenu (standard checkmark for `state: "on"`);
86// Android renders the token-styled AndroidAnchoredMenu, since the native
87// AppCompat popup can't be themed past its stock animation, metrics, and
88// submenu chrome.
77export function ControlPillMenu(89export function ControlPillMenu(
78 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & {90 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> & {
79 readonly children: ReactNode;91 readonly children: ReactNode;
92 readonly className?: string;
80 },93 },
81) {94) {
82 const isDarkMode = useColorScheme() === "dark";95 const isDarkMode = useColorScheme() === "dark";
8396
97 if (Platform.OS === "android") {
98 // Long-press menus keep their child interactive: the child element gets
99 // an injected onLongPress (mirroring the iOS context-menu interaction)
100 // so its own tap handling still works.
101 if (props.shouldOpenOnLongPress && isValidElement(props.children)) {
102 const child = props.children as ReactElement<{ onLongPress?: () => void }>;
103 return (
104 <AndroidAnchoredMenu
105 actions={props.actions}
106 className={props.className}
107 title={props.title}
108 style={props.style}
109 onPressAction={props.onPressAction}
110 >
111 {(open) =>
112 cloneElement(child, {
113 onLongPress: () => {
114 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
115 open();
116 },
117 })
118 }
119 </AndroidAnchoredMenu>
120 );
121 }
122 return (
123 <AndroidAnchoredMenu
124 actions={props.actions}
125 className={props.className}
126 title={props.title}
127 style={props.style}
128 onPressAction={props.onPressAction}
129 >
130 {props.children}
131 </AndroidAnchoredMenu>
132 );
133 }
134
135 const { className: _className, ...menuProps } = props;
84 return (136 return (
85 <MenuView {...props} themeVariant={isDarkMode ? "dark" : "light"}>137 <MenuView {...menuProps} themeVariant={isDarkMode ? "dark" : "light"}>
86 {props.children}138 {menuProps.children}
87 </MenuView>139 </MenuView>
88 );140 );
89}141}
apps/mobile/src/components/CopyTextButton.tsx+6-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../components/AppSymbol";
2import { memo, useEffect, useRef, useState } from "react";2import { memo, useEffect, useRef, useState } from "react";
3import { Pressable, type ColorValue } from "react-native";3import { Pressable, type ColorValue } from "react-native";
44
...@@ -58,7 +58,11 @@ export const CopyTextButton = memo(function CopyTextButton(props: {...@@ -58,7 +58,11 @@ export const CopyTextButton = memo(function CopyTextButton(props: {
58 })}58 })}
59 >59 >
60 <SymbolView60 <SymbolView
61 name={copied ? "checkmark" : "doc.on.doc"}61 name={
62 copied
63 ? { ios: "checkmark", android: "check" }
64 : { ios: "doc.on.doc", android: "content_copy" }
65 }
62 size={props.iconSize ?? 13}66 size={props.iconSize ?? 13}
63 tintColor={copied ? (props.copiedTintColor ?? props.tintColor) : props.tintColor}67 tintColor={copied ? (props.copiedTintColor ?? props.tintColor) : props.tintColor}
64 type="monochrome"68 type="monochrome"
apps/mobile/src/components/OverlayPortal.tsx created+69
...@@ -0,0 +1,69 @@
1import { type ReactNode, useEffect, useRef, useState } from "react";
2import { View } from "react-native";
3
4// Minimal in-tree portal for Android overlays. AndroidAnchoredMenu projects
5// its dropdown here instead of into an RN Modal: a Modal is a separate native
6// window, so presenting one moves window focus and closes the soft keyboard —
7// which matters for menus anchored to the keyboard-sticky composer pills.
8type Entries = ReadonlyMap<number, ReactNode>;
9type Listener = (entries: Entries) => void;
10
11let nextKey = 0;
12const entries = new Map<number, ReactNode>();
13const listeners = new Set<Listener>();
14
15function emit() {
16 const snapshot = new Map(entries);
17 for (const listener of listeners) {
18 listener(snapshot);
19 }
20}
21
22/** Projects children into the app-root OverlayPortalHost. */
23export function OverlayPortal(props: { readonly children: ReactNode }) {
24 const keyRef = useRef<number | null>(null);
25 keyRef.current ??= nextKey++;
26 const key = keyRef.current;
27
28 // No dependency array: re-project after every render so the host always
29 // shows the current content (menus re-render while open — drill-in, theme).
30 useEffect(() => {
31 entries.set(key, props.children);
32 emit();
33 });
34
35 useEffect(
36 () => () => {
37 entries.delete(key);
38 emit();
39 },
40 [key],
41 );
42
43 return null;
44}
45
46/** Mounted once at the app root, above the navigation container. */
47export function OverlayPortalHost() {
48 const [current, setCurrent] = useState<Entries>(() => new Map());
49
50 useEffect(() => {
51 listeners.add(setCurrent);
52 return () => {
53 listeners.delete(setCurrent);
54 };
55 }, []);
56
57 if (current.size === 0) {
58 return null;
59 }
60 return (
61 <View pointerEvents="box-none" className="absolute inset-0">
62 {[...current.entries()].map(([key, node]) => (
63 <View key={key} pointerEvents="box-none" className="absolute inset-0">
64 {node}
65 </View>
66 ))}
67 </View>
68 );
69}
apps/mobile/src/components/PierreEntryIcon.tsx+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../components/AppSymbol";
2import { Image, type ImageStyle, type StyleProp } from "react-native";2import { Image, type ImageStyle, type StyleProp } from "react-native";
33
4import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";4import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
apps/mobile/src/components/ProjectFavicon.tsx+5-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "./AppSymbol";
2import { Image } from "expo-image";2import { Image } from "expo-image";
3import { useState } from "react";3import { useState } from "react";
4import { View } from "react-native";4import { View } from "react-native";
...@@ -12,6 +12,7 @@ const loadedFaviconUrls = new Set<string>();...@@ -12,6 +12,7 @@ const loadedFaviconUrls = new Set<string>();
12/* ─── Component ──────────────────────────────────────────────────────── */12/* ─── Component ──────────────────────────────────────────────────────── */
13export function ProjectFavicon(props: {13export function ProjectFavicon(props: {
14 readonly environmentId: EnvironmentId;14 readonly environmentId: EnvironmentId;
15 readonly open?: boolean;
15 readonly size?: number;16 readonly size?: number;
16 readonly projectTitle: string;17 readonly projectTitle: string;
17 readonly workspaceRoot?: string | null;18 readonly workspaceRoot?: string | null;
...@@ -28,6 +29,7 @@ export function ProjectFavicon(props: {...@@ -28,6 +29,7 @@ export function ProjectFavicon(props: {
28 <ProjectFaviconImage29 <ProjectFaviconImage
29 key={faviconUrl}30 key={faviconUrl}
30 faviconUrl={faviconUrl}31 faviconUrl={faviconUrl}
32 open={props.open}
31 projectTitle={props.projectTitle}33 projectTitle={props.projectTitle}
32 size={size}34 size={size}
33 />35 />
...@@ -36,6 +38,7 @@ export function ProjectFavicon(props: {...@@ -36,6 +38,7 @@ export function ProjectFavicon(props: {
3638
37function ProjectFaviconImage(props: {39function ProjectFaviconImage(props: {
38 readonly faviconUrl: string | null;40 readonly faviconUrl: string | null;
41 readonly open?: boolean;
39 readonly projectTitle: string;42 readonly projectTitle: string;
40 readonly size: number;43 readonly size: number;
41}) {44}) {
...@@ -59,7 +62,7 @@ function ProjectFaviconImage(props: {...@@ -59,7 +62,7 @@ function ProjectFaviconImage(props: {
59 {/* Folder icon fallback (matches web's FolderIcon) */}62 {/* Folder icon fallback (matches web's FolderIcon) */}
60 {!showImage ? (63 {!showImage ? (
61 <SymbolView64 <SymbolView
62 name="folder.fill"65 name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }}
63 size={props.size * 0.78}66 size={props.size * 0.78}
64 tintColor={iconMuted}67 tintColor={iconMuted}
65 type="monochrome"68 type="monochrome"
apps/mobile/src/components/T3Wordmark.tsx created+23
...@@ -0,0 +1,23 @@
1import type { ColorValue } from "react-native";
2import Svg, { Path } from "react-native-svg";
3
4/**
5 * The "T3" brand mark, matching the desktop sidebar's T3Wordmark SVG
6 * (apps/web Sidebar.tsx). Width derives from the viewBox aspect ratio.
7 */
8export function T3Wordmark(props: { readonly height: number; readonly color: ColorValue }) {
9 const aspectRatio = 94.3941 / 56.96;
10 return (
11 <Svg
12 accessibilityLabel="T3"
13 height={props.height}
14 width={props.height * aspectRatio}
15 viewBox="15.5309 37 94.3941 56.96"
16 >
17 <Path
18 d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
19 fill={props.color}
20 />
21 </Svg>
22 );
23}
apps/mobile/src/features/agent-awareness/capabilities.ts created+5
...@@ -0,0 +1,5 @@
1import Constants from "expo-constants";
2
3export function supportsAgentAwarenessPush() {
4 return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true;
5}
apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts+2
...@@ -60,6 +60,8 @@ const testLayer = Layer.mergeAll(...@@ -60,6 +60,8 @@ const testLayer = Layer.mergeAll(
60 loadAgentAwarenessRegistrationRecord: Effect.succeed(null),60 loadAgentAwarenessRegistrationRecord: Effect.succeed(null),
61 saveAgentAwarenessRegistrationRecord: () => Effect.void,61 saveAgentAwarenessRegistrationRecord: () => Effect.void,
62 clearAgentAwarenessRegistrationRecord: Effect.void,62 clearAgentAwarenessRegistrationRecord: Effect.void,
63 loadRecentThreadShortcuts: Effect.succeed([]),
64 saveRecentThreadShortcuts: () => Effect.void,
63 }),65 }),
64 ),66 ),
65);67);
apps/mobile/src/features/agent-awareness/registrationPayload.ts+4-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";1import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
22
3import type { Preferences } from "../../persistence/mobile-preferences";3import type { Preferences } from "../../persistence/mobile-preferences";
4import { supportsAgentAwarenessPush } from "./capabilities";
45
5// Development builds are Xcode-signed and receive sandbox APNs tokens;6// Development builds are Xcode-signed and receive sandbox APNs tokens;
6// preview and production builds are distribution-signed and use production7// preview and production builds are distribution-signed and use production
...@@ -21,7 +22,8 @@ export function makeRelayDeviceRegistrationRequest(input: {...@@ -21,7 +22,8 @@ export function makeRelayDeviceRegistrationRequest(input: {
21 readonly notificationsEnabled: boolean;22 readonly notificationsEnabled: boolean;
22 readonly preferences: Preferences;23 readonly preferences: Preferences;
23}): RelayDeviceRegistrationRequest {24}): RelayDeviceRegistrationRequest {
24 const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false;25 const pushAvailable = supportsAgentAwarenessPush();
26 const liveActivitiesEnabled = pushAvailable && input.preferences.liveActivitiesEnabled !== false;
25 return {27 return {
26 deviceId: input.deviceId,28 deviceId: input.deviceId,
27 label: input.label,29 label: input.label,
...@@ -34,7 +36,7 @@ export function makeRelayDeviceRegistrationRequest(input: {...@@ -34,7 +36,7 @@ export function makeRelayDeviceRegistrationRequest(input: {
34 ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),36 ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
35 preferences: {37 preferences: {
36 liveActivitiesEnabled,38 liveActivitiesEnabled,
37 notificationsEnabled: input.notificationsEnabled,39 notificationsEnabled: pushAvailable && input.notificationsEnabled,
38 notifyOnApproval: true,40 notifyOnApproval: true,
39 notifyOnInput: true,41 notifyOnInput: true,
40 notifyOnCompletion: true,42 notifyOnCompletion: true,
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+20
...@@ -287,6 +287,26 @@ describe("makeRelayDeviceRegistrationRequest", () => {...@@ -287,6 +287,26 @@ describe("makeRelayDeviceRegistrationRequest", () => {
287 expect(resolveApsEnvironment(undefined)).toBe("production");287 expect(resolveApsEnvironment(undefined)).toBe("production");
288 });288 });
289289
290 it("disables push features in Personal Team relay registrations", () => {
291 Constants.expoConfig!.extra = { iosPersonalTeamBuild: true };
292
293 expect(
294 makeRelayDeviceRegistrationRequest({
295 deviceId: "device-1",
296 label: "Julius's iPhone",
297 iosMajorVersion: 18,
298 appVersion: "1.0.0",
299 pushToken: "apns-token",
300 pushToStartToken: "push-to-start-token",
301 notificationsEnabled: true,
302 preferences: {},
303 }).preferences,
304 ).toMatchObject({
305 liveActivitiesEnabled: false,
306 notificationsEnabled: false,
307 });
308 });
309
290 it("marks notification delivery disabled when APNs permission is unavailable", () => {310 it("marks notification delivery disabled when APNs permission is unavailable", () => {
291 expect(311 expect(
292 makeRelayDeviceRegistrationRequest({312 makeRelayDeviceRegistrationRequest({
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+2-1
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31} from "../../persistence/imperative";31} from "../../persistence/imperative";
32import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";32import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
33import { resolveCloudPublicConfig } from "../cloud/publicConfig";33import { resolveCloudPublicConfig } from "../cloud/publicConfig";
34import { supportsAgentAwarenessPush } from "./capabilities";
34import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";35import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
3536
36const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000;37const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000;
...@@ -249,7 +250,7 @@ function iosMajorVersion(): number {...@@ -249,7 +250,7 @@ function iosMajorVersion(): number {
249250
250function nativePushTokenRegistration(observedPushToken?: string) {251function nativePushTokenRegistration(observedPushToken?: string) {
251 return Effect.gen(function* () {252 return Effect.gen(function* () {
252 if (!canRegisterRemoteLiveActivities()) {253 if (!canRegisterRemoteLiveActivities() || !supportsAgentAwarenessPush()) {
253 return { notificationsEnabled: false, pushToken: null };254 return { notificationsEnabled: false, pushToken: null };
254 }255 }
255 if (observedPushToken) {256 if (observedPushToken) {
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+146-41
...@@ -6,9 +6,11 @@ import { LegendList } from "@legendapp/list/react-native";...@@ -6,9 +6,11 @@ import { LegendList } from "@legendapp/list/react-native";
6import type { EnvironmentId } from "@t3tools/contracts";6import type { EnvironmentId } from "@t3tools/contracts";
7import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
8import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";8import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
9import { SymbolView } from "expo-symbols";9import { SymbolView } from "../../components/AppSymbol";
10import { useNavigation } from "@react-navigation/native";
10import { useCallback, useMemo, useRef, type ComponentProps } from "react";11import { useCallback, useMemo, useRef, type ComponentProps } from "react";
11import {12import {
13 TextInput,
12 ActivityIndicator,14 ActivityIndicator,
13 Platform,15 Platform,
14 Pressable,16 Pressable,
...@@ -23,6 +25,7 @@ import { AppText as Text } from "../../components/AppText";...@@ -23,6 +25,7 @@ import { AppText as Text } from "../../components/AppText";
23import { ControlPillMenu } from "../../components/ControlPill";25import { ControlPillMenu } from "../../components/ControlPill";
24import { EmptyState } from "../../components/EmptyState";26import { EmptyState } from "../../components/EmptyState";
25import { ProjectFavicon } from "../../components/ProjectFavicon";27import { ProjectFavicon } from "../../components/ProjectFavicon";
28import { useSafeAreaInsets } from "react-native-safe-area-context";
26import { relativeTime } from "../../lib/time";29import { relativeTime } from "../../lib/time";
27import { useThemeColor } from "../../lib/useThemeColor";30import { useThemeColor } from "../../lib/useThemeColor";
28import { ThreadSwipeable } from "../home/thread-swipe-actions";31import { ThreadSwipeable } from "../home/thread-swipe-actions";
...@@ -34,20 +37,6 @@ export interface ArchivedThreadsHeaderEnvironment {...@@ -34,20 +37,6 @@ export interface ArchivedThreadsHeaderEnvironment {
34 readonly label: string;37 readonly label: string;
35}38}
3639
37const THREAD_ACTIONS: MenuAction[] = [
38 {
39 id: "unarchive",
40 title: "Unarchive",
41 image: "arrow.uturn.backward",
42 },
43 {
44 id: "delete",
45 title: "Delete",
46 image: "trash",
47 attributes: { destructive: true },
48 },
49];
50
51type ArchivedThreadListItem =40type ArchivedThreadListItem =
52 | {41 | {
53 readonly kind: "project";42 readonly kind: "project";
...@@ -66,6 +55,7 @@ type ArchivedThreadListItem =...@@ -66,6 +55,7 @@ type ArchivedThreadListItem =
6655
67function ArchivedThreadsHeader(props: {56function ArchivedThreadsHeader(props: {
68 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;57 readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;
58 readonly searchQuery: string;
69 readonly selectedEnvironmentId: EnvironmentId | null;59 readonly selectedEnvironmentId: EnvironmentId | null;
70 readonly sortOrder: ArchivedThreadSortOrder;60 readonly sortOrder: ArchivedThreadSortOrder;
71 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;61 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
...@@ -74,9 +64,140 @@ function ArchivedThreadsHeader(props: {...@@ -74,9 +64,140 @@ function ArchivedThreadsHeader(props: {
74 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;64 readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
75}) {65}) {
76 const { width } = useWindowDimensions();66 const { width } = useWindowDimensions();
67 const navigation = useNavigation();
68 const insets = useSafeAreaInsets();
77 const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";69 const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";
70 const searchIconColor = useThemeColor("--color-icon");
71 const searchTextColor = useThemeColor("--color-foreground");
78 const usesNativeChrome = Platform.OS === "ios";72 const usesNativeChrome = Platform.OS === "ios";
79 const usesCompactMailToolbar = Platform.OS === "ios" && width < 700;73 const usesCompactMailToolbar = Platform.OS === "ios" && width < 700;
74 const androidFilterActions = useMemo<MenuAction[]>(
75 () => [
76 {
77 id: "environment",
78 title: "Environment",
79 subactions: [
80 {
81 id: "environment:all",
82 title: "All environments",
83 state: props.selectedEnvironmentId === null ? ("on" as const) : undefined,
84 },
85 ...props.environments.map((environment) => ({
86 id: `environment:${environment.environmentId}`,
87 title: environment.label,
88 state:
89 props.selectedEnvironmentId === environment.environmentId
90 ? ("on" as const)
91 : undefined,
92 })),
93 ],
94 },
95 {
96 id: "sort",
97 title: "Sort by archived date",
98 subactions: [
99 {
100 id: "sort:newest",
101 title: "Newest first",
102 state: props.sortOrder === "newest" ? ("on" as const) : undefined,
103 },
104 {
105 id: "sort:oldest",
106 title: "Oldest first",
107 state: props.sortOrder === "oldest" ? ("on" as const) : undefined,
108 },
109 ],
110 },
111 ],
112 [props.environments, props.selectedEnvironmentId, props.sortOrder],
113 );
114 const handleAndroidFilterAction = useCallback(
115 (event: { nativeEvent: { event: string } }) => {
116 const action = event.nativeEvent.event;
117 if (action === "environment:all") {
118 props.onEnvironmentChange(null);
119 } else if (action.startsWith("environment:")) {
120 props.onEnvironmentChange(action.slice("environment:".length) as EnvironmentId);
121 } else if (action === "sort:newest") {
122 props.onSortOrderChange("newest");
123 } else if (action === "sort:oldest") {
124 props.onSortOrderChange("oldest");
125 }
126 },
127 [props.onEnvironmentChange, props.onSortOrderChange],
128 );
129
130 if (Platform.OS === "android") {
131 // Single header row matching the app's Android chrome (AndroidScreenHeader
132 // palette): back chevron, inline search, filter menu.
133 return (
134 <>
135 <NativeStackScreenOptions options={{ headerShown: false }} />
136 <View
137 className="border-b border-header-border bg-header px-3 pb-2.5"
138 style={{
139 paddingTop: Math.max(insets.top, 12),
140 }}
141 >
142 <View className="min-h-12 flex-row items-center gap-2">
143 <Pressable
144 accessibilityLabel="Navigate up"
145 accessibilityRole="button"
146 hitSlop={8}
147 onPress={() => navigation.goBack()}
148 className="size-11 items-center justify-center"
149 >
150 <SymbolView
151 name="chevron.left"
152 size={24}
153 tintColor={searchTextColor}
154 type="monochrome"
155 />
156 </Pressable>
157 <View className="min-h-11 flex-1 flex-row items-center gap-2.5 rounded-2xl bg-input px-3.5">
158 <SymbolView
159 name="magnifyingglass"
160 size={17}
161 tintColor={searchIconColor}
162 type="monochrome"
163 />
164 <TextInput
165 accessibilityLabel="Search archived threads"
166 autoCapitalize="none"
167 onChangeText={props.onSearchQueryChange}
168 value={props.searchQuery}
169 placeholder="Search archived threads"
170 placeholderTextColorClassName="accent-placeholder"
171 className="flex-1 py-2 text-base font-sans text-foreground"
172 />
173 </View>
174 <ControlPillMenu
175 actions={androidFilterActions}
176 isAnchoredToRight
177 onPressAction={handleAndroidFilterAction}
178 >
179 <Pressable
180 accessibilityLabel="Filter and sort archived threads"
181 accessibilityRole="button"
182 className="size-11 items-center justify-center rounded-full bg-subtle"
183 >
184 <SymbolView
185 name={
186 hasCustomFilter
187 ? "line.3.horizontal.decrease.circle.fill"
188 : "line.3.horizontal.decrease.circle"
189 }
190 size={16}
191 tintColor={searchIconColor}
192 type="monochrome"
193 />
194 </Pressable>
195 </ControlPillMenu>
196 </View>
197 </View>
198 </>
199 );
200 }
80 const archiveFilterMenu = {201 const archiveFilterMenu = {
81 title: "Archived thread options",202 title: "Archived thread options",
82 items: [203 items: [
...@@ -279,20 +400,18 @@ function ArchivedThreadRow(props: {...@@ -279,20 +400,18 @@ function ArchivedThreadRow(props: {
279 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>400 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
280 Boolean(part),401 Boolean(part),
281 );402 );
282 const handleMenuAction = useCallback(
283 (event: { nativeEvent: { event: string } }) => {
284 if (event.nativeEvent.event === "unarchive") {
285 props.onUnarchive();
286 } else if (event.nativeEvent.event === "delete") {
287 props.onDelete();
288 }
289 },
290 [props.onDelete, props.onUnarchive],
291 );
292
293 return (403 return (
294 <ThreadSwipeable404 <ThreadSwipeable
295 backgroundColor={cardColor}405 backgroundColor={cardColor}
406 // Round + clip the swipeable container so the group's corners stay
407 // rounded while rows swipe; the row itself stays square inside.
408 containerStyle={{
409 borderTopLeftRadius: props.isFirst ? 20 : 0,
410 borderTopRightRadius: props.isFirst ? 20 : 0,
411 borderBottomLeftRadius: props.isLast ? 20 : 0,
412 borderBottomRightRadius: props.isLast ? 20 : 0,
413 overflow: "hidden",
414 }}
296 fullSwipeWidth={windowWidth - 32}415 fullSwipeWidth={windowWidth - 32}
297 onDelete={props.onDelete}416 onDelete={props.onDelete}
298 onSwipeableClose={props.onSwipeableClose}417 onSwipeableClose={props.onSwipeableClose}
...@@ -312,10 +431,6 @@ function ArchivedThreadRow(props: {...@@ -312,10 +431,6 @@ function ArchivedThreadRow(props: {
312 style={{431 style={{
313 borderBottomColor: separatorColor,432 borderBottomColor: separatorColor,
314 borderBottomWidth: props.isLast ? 0 : 1,433 borderBottomWidth: props.isLast ? 0 : 1,
315 borderTopLeftRadius: props.isFirst ? 20 : 0,
316 borderTopRightRadius: props.isFirst ? 20 : 0,
317 borderBottomLeftRadius: props.isLast ? 20 : 0,
318 borderBottomRightRadius: props.isLast ? 20 : 0,
319 }}434 }}
320 >435 >
321 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">436 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
...@@ -351,17 +466,6 @@ function ArchivedThreadRow(props: {...@@ -351,17 +466,6 @@ function ArchivedThreadRow(props: {
351 </View>466 </View>
352 ) : null}467 ) : null}
353 </View>468 </View>
354
355 <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}>
356 <Pressable
357 accessibilityLabel={`Actions for ${props.thread.title}`}
358 accessibilityRole="button"
359 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
360 hitSlop={6}
361 >
362 <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />
363 </Pressable>
364 </ControlPillMenu>
365 </View>469 </View>
366 )}470 )}
367 </ThreadSwipeable>471 </ThreadSwipeable>
...@@ -503,6 +607,7 @@ export function ArchivedThreadsScreen(props: {...@@ -503,6 +607,7 @@ export function ArchivedThreadsScreen(props: {
503 <View className="flex-1 bg-sheet">607 <View className="flex-1 bg-sheet">
504 <ArchivedThreadsHeader608 <ArchivedThreadsHeader
505 environments={props.environments}609 environments={props.environments}
610 searchQuery={props.searchQuery}
506 onEnvironmentChange={props.onEnvironmentChange}611 onEnvironmentChange={props.onEnvironmentChange}
507 onRefresh={props.onRefresh}612 onRefresh={props.onRefresh}
508 onSearchQueryChange={props.onSearchQueryChange}613 onSearchQueryChange={props.onSearchQueryChange}
apps/mobile/src/features/cloud/linkEnvironment.test.ts+2
...@@ -94,6 +94,8 @@ function cloudClientLayer() {...@@ -94,6 +94,8 @@ function cloudClientLayer() {
94 loadAgentAwarenessRegistrationRecord: Effect.succeed(null),94 loadAgentAwarenessRegistrationRecord: Effect.succeed(null),
95 saveAgentAwarenessRegistrationRecord: () => Effect.void,95 saveAgentAwarenessRegistrationRecord: () => Effect.void,
96 clearAgentAwarenessRegistrationRecord: Effect.void,96 clearAgentAwarenessRegistrationRecord: Effect.void,
97 loadRecentThreadShortcuts: Effect.succeed([]),
98 saveRecentThreadShortcuts: () => Effect.void,
97 }),99 }),
98 ),100 ),
99 ManagedRelay.layer({101 ManagedRelay.layer({
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useAuth } from "@clerk/expo";1import { useAuth } from "@clerk/expo";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "../../components/AppSymbol";
3import {3import {
4 connectionStatusText,4 connectionStatusText,
5 type EnvironmentConnectionPhase,5 type EnvironmentConnectionPhase,
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../../components/AppSymbol";
2import { connectionStatusText } from "@t3tools/client-runtime/connection";2import { connectionStatusText } from "@t3tools/client-runtime/connection";
3import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";3import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
4import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
apps/mobile/src/features/connection/ConnectionSheetButton.tsx+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../../components/AppSymbol";
2import { Platform, Pressable } from "react-native";2import { Platform, Pressable } from "react-native";
3import { useThemeColor } from "../../lib/useThemeColor";3import { useThemeColor } from "../../lib/useThemeColor";
44
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+37-14
...@@ -3,10 +3,11 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac...@@ -3,10 +3,11 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
3import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";3import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
4import { AsyncResult } from "effect/unstable/reactivity";4import { AsyncResult } from "effect/unstable/reactivity";
5import { useCallback, useEffect, useState } from "react";5import { useCallback, useEffect, useState } from "react";
6import { Alert, ScrollView, View } from "react-native";6import { Alert, Platform, ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { useThemeColor } from "../../lib/useThemeColor";8import { useThemeColor } from "../../lib/useThemeColor";
99
10import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
10import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";11import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
11import { ErrorBanner } from "../../components/ErrorBanner";12import { ErrorBanner } from "../../components/ErrorBanner";
12import { ConnectionSheetButton } from "./ConnectionSheetButton";13import { ConnectionSheetButton } from "./ConnectionSheetButton";
...@@ -136,23 +137,45 @@ export function ConnectionsNewRouteScreen({...@@ -136,23 +137,45 @@ export function ConnectionsNewRouteScreen({
136 <View collapsable={false} className="flex-1 bg-sheet">137 <View collapsable={false} className="flex-1 bg-sheet">
137 <NativeStackScreenOptions138 <NativeStackScreenOptions
138 options={{139 options={{
140 // Android renders its own in-screen header below instead of the native bar.
141 ...(Platform.OS === "android" ? { headerShown: false } : null),
139 title: showScanner ? "Scan QR Code" : "Add Environment",142 title: showScanner ? "Scan QR Code" : "Add Environment",
140 }}143 }}
141 />144 />
142 <NativeHeaderToolbar placement="right">145 {Platform.OS === "android" ? (
143 <NativeHeaderToolbar.Button146 <AndroidScreenHeader
144 icon={showScanner ? "xmark" : "qrcode.viewfinder"}147 title={showScanner ? "Scan QR Code" : "Add Environment"}
145 onPress={() => {148 onBack={() => navigation.goBack()}
146 if (showScanner) {149 actions={[
147 closeScanner();150 {
148 } else {151 accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code",
149 void openScanner();152 icon: showScanner ? "xmark" : "camera",
150 }153 onPress: () => {
151 }}154 if (showScanner) {
152 separateBackground155 closeScanner();
153 tintColor={headerIconColor}156 } else {
157 void openScanner();
158 }
159 },
160 },
161 ]}
154 />162 />
155 </NativeHeaderToolbar>163 ) : (
164 <NativeHeaderToolbar placement="right">
165 <NativeHeaderToolbar.Button
166 icon={showScanner ? "xmark" : "qrcode.viewfinder"}
167 onPress={() => {
168 if (showScanner) {
169 closeScanner();
170 } else {
171 void openScanner();
172 }
173 }}
174 separateBackground
175 tintColor={headerIconColor}
176 />
177 </NativeHeaderToolbar>
178 )}
156179
157 <ScrollView180 <ScrollView
158 contentInsetAdjustmentBehavior="automatic"181 contentInsetAdjustmentBehavior="automatic"
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+23-8
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useNavigation } from "@react-navigation/native";2import { useNavigation } from "@react-navigation/native";
3import { SymbolView } from "expo-symbols";3import { SymbolView } from "../../components/AppSymbol";
4import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
5import { useCallback, useState } from "react";5import { useCallback, useState } from "react";
6import { ScrollView, View } from "react-native";6import { Platform, ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { useThemeColor } from "../../lib/useThemeColor";8import { useThemeColor } from "../../lib/useThemeColor";
99
10import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
10import { AppText as Text } from "../../components/AppText";11import { AppText as Text } from "../../components/AppText";
11import { cn } from "../../lib/cn";12import { cn } from "../../lib/cn";
12import { useRemoteConnections } from "../../state/use-remote-environment-registry";13import { useRemoteConnections } from "../../state/use-remote-environment-registry";
...@@ -32,13 +33,27 @@ export function ConnectionsRouteScreen() {...@@ -32,13 +33,27 @@ export function ConnectionsRouteScreen() {
3233
33 return (34 return (
34 <View collapsable={false} className="flex-1 bg-sheet">35 <View collapsable={false} className="flex-1 bg-sheet">
35 <NativeHeaderToolbar placement="right">36 {Platform.OS === "android" ? (
36 <NativeHeaderToolbar.Button37 <AndroidScreenHeader
37 icon="plus"38 title="Environments"
38 onPress={() => navigation.navigate("ConnectionsNew")}39 onBack={() => navigation.goBack()}
39 separateBackground40 actions={[
41 {
42 accessibilityLabel: "Add environment",
43 icon: "plus",
44 onPress: () => navigation.navigate("ConnectionsNew"),
45 },
46 ]}
40 />47 />
41 </NativeHeaderToolbar>48 ) : (
49 <NativeHeaderToolbar placement="right">
50 <NativeHeaderToolbar.Button
51 icon="plus"
52 onPress={() => navigation.navigate("ConnectionsNew")}
53 separateBackground
54 />
55 </NativeHeaderToolbar>
56 )}
42 <ScrollView57 <ScrollView
43 contentInsetAdjustmentBehavior="automatic"58 contentInsetAdjustmentBehavior="automatic"
44 showsVerticalScrollIndicator={false}59 showsVerticalScrollIndicator={false}
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+1-1
...@@ -2,7 +2,7 @@ import {...@@ -2,7 +2,7 @@ import {
2 type EnvironmentConnectionPhase,2 type EnvironmentConnectionPhase,
3 type EnvironmentConnectionPresentation,3 type EnvironmentConnectionPresentation,
4} from "@t3tools/client-runtime/connection";4} from "@t3tools/client-runtime/connection";
5import { SymbolView } from "expo-symbols";5import { SymbolView } from "../../components/AppSymbol";
6import { ActivityIndicator, Pressable, View } from "react-native";6import { ActivityIndicator, Pressable, View } from "react-native";
77
8import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
apps/mobile/src/features/files/FileMarkdownPreview.tsx+3
...@@ -78,11 +78,14 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {...@@ -78,11 +78,14 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
78 link,78 link,
79 blockquote: blockquoteBorder,79 blockquote: blockquoteBorder,
80 border: horizontalRule,80 border: horizontalRule,
81 surface: "transparent",
81 surfaceLight: blockquoteBackground,82 surfaceLight: blockquoteBackground,
82 accent: link,83 accent: link,
83 tableBorder: horizontalRule,84 tableBorder: horizontalRule,
84 tableHeader: blockquoteBackground,85 tableHeader: blockquoteBackground,
85 tableHeaderText: strong,86 tableHeaderText: strong,
87 tableRowOdd: blockquoteBackground,
88 tableRowEven: "transparent",
86 code: codeText,89 code: codeText,
87 codeBackground,90 codeBackground,
88 },91 },
apps/mobile/src/features/files/FileTreeBrowser.tsx+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { ProjectEntry } from "@t3tools/contracts";1import type { ProjectEntry } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "../../components/AppSymbol";
3import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";3import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4import {4import {
5 ActivityIndicator,5 ActivityIndicator,
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+56-27
...@@ -1,14 +1,7 @@...@@ -1,14 +1,7 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";3import { useCallback, useEffect, useRef, useState } from "react";
4import {4import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";
5 ActivityIndicator,
6 Platform,
7 Pressable,
8 ScrollView,
9 useColorScheme,
10 View,
11} from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
13import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";6import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
14import {7import {
...@@ -18,10 +11,11 @@ import {...@@ -18,10 +11,11 @@ import {
18 ThreadId,11 ThreadId,
19} from "@t3tools/contracts";12} from "@t3tools/contracts";
2013
21import { AppText as Text } from "../../components/AppText";14import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
15import { SymbolView } from "../../components/AppSymbol";
16import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
22import { EmptyState } from "../../components/EmptyState";17import { EmptyState } from "../../components/EmptyState";
23import { LoadingScreen } from "../../components/LoadingScreen";18import { LoadingScreen } from "../../components/LoadingScreen";
24import { cn } from "../../lib/cn";
25import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";19import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
26import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";20import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
27import { tryOpenExternalUrl } from "../../lib/openExternalUrl";21import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
...@@ -48,7 +42,6 @@ import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";...@@ -48,7 +42,6 @@ import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
48import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";42import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
49import {43import {
50 basename,44 basename,
51 fileBreadcrumbs,
52 isBrowserPreviewFile,45 isBrowserPreviewFile,
53 isImagePreviewFile,46 isImagePreviewFile,
54 isMarkdownPreviewFile,47 isMarkdownPreviewFile,
...@@ -247,7 +240,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -247,7 +240,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
247 useAdaptiveWorkspaceLayout();240 useAdaptiveWorkspaceLayout();
248 const [searchQuery, setSearchQuery] = useState("");241 const [searchQuery, setSearchQuery] = useState("");
249 const colorScheme = useColorScheme();242 const colorScheme = useColorScheme();
243 const isAndroid = Platform.OS === "android";
250 const highlightTheme = colorScheme === "dark" ? "dark" : "light";244 const highlightTheme = colorScheme === "dark" ? "dark" : "light";
245 const iconColor = String(useThemeColor("--color-icon-muted"));
251 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(246 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
252 props.route.params,247 props.route.params,
253 );248 );
...@@ -367,6 +362,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -367,6 +362,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
367 Only genuinely dynamic options are set here. */}362 Only genuinely dynamic options are set here. */}
368 <NativeStackScreenOptions363 <NativeStackScreenOptions
369 options={{364 options={{
365 headerShown: !isAndroid,
370 unstable_headerSubtitle:366 unstable_headerSubtitle:
371 Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,367 Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,
372 // No refresh button: the list already supports pull-to-refresh.368 // No refresh button: the list already supports pull-to-refresh.
...@@ -395,22 +391,55 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -395,22 +391,55 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
395 },391 },
396 }}392 }}
397 />393 />
398 {layout.usesSplitView ? (394 {isAndroid ? (
399 <NativeHeaderToolbar placement="left">395 <>
400 <NativeHeaderToolbar.Button396 <AndroidScreenHeader
401 accessibilityLabel={panes.primarySidebarVisible ? "Maximize files" : "Show threads"}397 title="Files"
402 icon={398 subtitle={projectName}
403 panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"399 onBack={handleReturnToThread}
404 }400 actions={[
405 onPress={togglePrimarySidebar}401 {
406 separateBackground402 accessibilityLabel: "Refresh files",
403 icon: "arrow.clockwise",
404 onPress: entriesQuery.refresh,
405 },
406 ]}
407 />407 />
408 </NativeHeaderToolbar>408 <View className="flex-row items-center gap-2 border-b border-border px-3 py-2">
409 ) : null}409 <SymbolView name="magnifyingglass" size={17} tintColor={iconColor} type="monochrome" />
410 {usesCompactMailToolbar ? null : (410 <TextInput
411 <NativeHeaderToolbar placement="bottom">411 accessibilityLabel="Search files"
412 <NativeHeaderToolbar.SearchBarSlot />412 autoCapitalize="none"
413 </NativeHeaderToolbar>413 autoCorrect={false}
414 className="min-h-10 flex-1 rounded-xl py-2 text-sm"
415 placeholder="Search files"
416 value={searchQuery}
417 onChangeText={setSearchQuery}
418 />
419 </View>
420 </>
421 ) : (
422 <>
423 {layout.usesSplitView ? (
424 <NativeHeaderToolbar placement="left">
425 <NativeHeaderToolbar.Button
426 accessibilityLabel={panes.primarySidebarVisible ? "Maximize files" : "Show threads"}
427 icon={
428 panes.primarySidebarVisible
429 ? "arrow.up.left.and.arrow.down.right"
430 : "sidebar.left"
431 }
432 onPress={togglePrimarySidebar}
433 separateBackground
434 />
435 </NativeHeaderToolbar>
436 ) : null}
437 {usesCompactMailToolbar ? null : (
438 <NativeHeaderToolbar placement="bottom">
439 <NativeHeaderToolbar.SearchBarSlot />
440 </NativeHeaderToolbar>
441 )}
442 </>
414 )}443 )}
415 <FileTreeBrowser444 <FileTreeBrowser
416 entries={entriesData?.entries ?? []}445 entries={entriesData?.entries ?? []}
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";1import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "../../components/AppSymbol";
3import { useCallback, useMemo, useState, type ComponentProps } from "react";3import { useCallback, useMemo, useState, type ComponentProps } from "react";
4import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native";4import { Platform, Pressable, useColorScheme, View, type NativeSyntheticEvent } from "react-native";
5import {5import {
apps/mobile/src/features/home/AndroidHomeFab.tsx created+51
...@@ -0,0 +1,51 @@
1import type { ReactNode } from "react";
2import { Platform, Pressable, View } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";
4
5import { SymbolView } from "../../components/AppSymbol";
6import { useThemeColor } from "../../lib/useThemeColor";
7
8/**
9 * Android-only wrapper that overlays a bottom-right new-task FAB on the home
10 * screen. Other platforms render children unchanged.
11 */
12export function AndroidHomeFabLayout(props: {
13 readonly onStartNewTask: () => void;
14 readonly children: ReactNode;
15}) {
16 if (Platform.OS !== "android") {
17 return <>{props.children}</>;
18 }
19
20 return <AndroidHomeFab {...props} />;
21}
22
23function AndroidHomeFab(props: {
24 readonly onStartNewTask: () => void;
25 readonly children: ReactNode;
26}) {
27 const insets = useSafeAreaInsets();
28 const primaryForegroundColor = useThemeColor("--color-primary-foreground");
29
30 return (
31 <View className="flex-1">
32 {props.children}
33 <Pressable
34 accessibilityLabel="New task"
35 accessibilityRole="button"
36 onPress={props.onStartNewTask}
37 className="absolute right-5 size-14 items-center justify-center rounded-full bg-primary shadow-lg"
38 style={{
39 bottom: Math.max(insets.bottom, 16) + 16,
40 }}
41 >
42 <SymbolView
43 name="square.and.pencil"
44 size={22}
45 tintColor={primaryForegroundColor}
46 type="monochrome"
47 />
48 </Pressable>
49 </View>
50 );
51}
apps/mobile/src/features/home/HomeHeader.tsx+215-4
...@@ -3,12 +3,16 @@ import type {...@@ -3,12 +3,16 @@ import type {
3 SidebarProjectGroupingMode,3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import type { MenuAction } from "@react-native-menu/menu";
6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";7import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { useCallback, useRef } from "react";8import { useCallback, useMemo, useRef } from "react";
8import { Platform } from "react-native";9import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";
9import type { SearchBarCommands } from "react-native-screens";10import type { SearchBarCommands } from "react-native-screens";
11import { useSafeAreaInsets } from "react-native-safe-area-context";
1012
11import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";13import { ControlPillMenu } from "../../components/ControlPill";
14import { SymbolView } from "../../components/AppSymbol";
15import { T3Wordmark } from "../../components/T3Wordmark";
12import { useThemeColor } from "../../lib/useThemeColor";16import { useThemeColor } from "../../lib/useThemeColor";
13import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";17import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
14import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";18import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
...@@ -26,10 +30,10 @@ import {...@@ -26,10 +30,10 @@ import {
26} from "./home-list-options";30} from "./home-list-options";
2731
28export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;32export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
29const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
3033
31export function HomeHeader(props: {34export function HomeHeader(props: {
32 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;35 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
36 readonly searchQuery: string;
33 readonly selectedEnvironmentId: EnvironmentId | null;37 readonly selectedEnvironmentId: EnvironmentId | null;
34 readonly projectSortOrder: HomeProjectSortOrder;38 readonly projectSortOrder: HomeProjectSortOrder;
35 readonly threadSortOrder: SidebarThreadSortOrder;39 readonly threadSortOrder: SidebarThreadSortOrder;
...@@ -42,6 +46,213 @@ export function HomeHeader(props: {...@@ -42,6 +46,213 @@ export function HomeHeader(props: {
42 readonly onOpenSettings: () => void;46 readonly onOpenSettings: () => void;
43 readonly onStartNewTask: () => void;47 readonly onStartNewTask: () => void;
44}) {48}) {
49 if (Platform.OS === "android") {
50 return <AndroidHomeHeader {...props} />;
51 }
52
53 return <IosHomeHeader {...props} />;
54}
55
56type HomeHeaderProps = Parameters<typeof HomeHeader>[0];
57
58function checkedMenuState(checked: boolean) {
59 return checked ? ("on" as const) : undefined;
60}
61
62function AndroidHomeHeader(props: HomeHeaderProps) {
63 const insets = useSafeAreaInsets();
64 const iconColor = useThemeColor("--color-icon");
65 const mutedColor = useThemeColor("--color-foreground-muted");
66 const hasCustomListOptions = hasCustomHomeListOptions(props);
67 const menuActions = useMemo<MenuAction[]>(
68 () => [
69 {
70 id: "environment",
71 title: "Environment",
72 subactions: [
73 {
74 id: "environment:all",
75 title: "All environments",
76 state: checkedMenuState(props.selectedEnvironmentId === null),
77 },
78 ...props.environments.map((environment) => ({
79 id: `environment:${environment.environmentId}`,
80 title: environment.label,
81 state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId),
82 })),
83 ],
84 },
85 {
86 id: "project-sort",
87 title: "Sort projects",
88 subactions: PROJECT_SORT_OPTIONS.map((option) => ({
89 id: `project-sort:${option.value}`,
90 title: option.label,
91 state: checkedMenuState(props.projectSortOrder === option.value),
92 })),
93 },
94 {
95 id: "thread-sort",
96 title: "Sort threads",
97 subactions: THREAD_SORT_OPTIONS.map((option) => ({
98 id: `thread-sort:${option.value}`,
99 title: option.label,
100 state: checkedMenuState(props.threadSortOrder === option.value),
101 })),
102 },
103 {
104 id: "project-grouping",
105 title: "Group projects",
106 subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({
107 id: `project-grouping:${option.value}`,
108 title: option.label,
109 state: checkedMenuState(props.projectGroupingMode === option.value),
110 })),
111 },
112 ],
113 [
114 props.environments,
115 props.projectGroupingMode,
116 props.projectSortOrder,
117 props.selectedEnvironmentId,
118 props.threadSortOrder,
119 ],
120 );
121 const handleMenuAction = useCallback(
122 (event: { nativeEvent: { event: string } }) => {
123 const id = event.nativeEvent.event;
124 if (id === "environment:all") {
125 props.onEnvironmentChange(null);
126 return;
127 }
128
129 if (id.startsWith("environment:")) {
130 const environmentId = id.slice("environment:".length);
131 const environment = props.environments.find(
132 (candidate) => candidate.environmentId === environmentId,
133 );
134 if (environment) {
135 props.onEnvironmentChange(environment.environmentId);
136 }
137 return;
138 }
139
140 const projectSort = PROJECT_SORT_OPTIONS.find(
141 (option) => id === `project-sort:${option.value}`,
142 );
143 if (projectSort) {
144 props.onProjectSortOrderChange(projectSort.value);
145 return;
146 }
147
148 const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`);
149 if (threadSort) {
150 props.onThreadSortOrderChange(threadSort.value);
151 return;
152 }
153
154 const grouping = PROJECT_GROUPING_OPTIONS.find(
155 (option) => id === `project-grouping:${option.value}`,
156 );
157 if (grouping) {
158 props.onProjectGroupingModeChange(grouping.value);
159 }
160 },
161 [props],
162 );
163
164 return (
165 <>
166 <NativeStackScreenOptions options={{ headerShown: false }} />
167 <View
168 className="border-b border-header-border bg-header px-4 pb-3"
169 style={{
170 paddingTop: Math.max(insets.top, 12),
171 }}
172 >
173 <View className="w-full max-w-[720px] self-center gap-3">
174 <View className="flex-row items-center gap-2.5">
175 <View className="flex-1 flex-row items-center gap-2">
176 {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */}
177 <T3Wordmark color={iconColor} height={15} />
178 <RNText className="-ml-0.5 text-[21px] font-t3-medium tracking-[-0.5px] text-foreground-muted">
179 Code
180 </RNText>
181 <View className="rounded-full bg-subtle px-2 py-0.75">
182 <RNText className="text-[11px] font-t3-bold tracking-[1.1px] text-foreground-muted uppercase">
183 Alpha
184 </RNText>
185 </View>
186 </View>
187
188 <ControlPillMenu
189 actions={menuActions}
190 isAnchoredToRight
191 onPressAction={handleMenuAction}
192 >
193 <Pressable
194 accessibilityLabel="Filter and sort threads"
195 accessibilityRole="button"
196 className="size-11 items-center justify-center rounded-full bg-subtle"
197 >
198 <SymbolView
199 name={
200 hasCustomListOptions
201 ? "line.3.horizontal.decrease.circle.fill"
202 : "line.3.horizontal.decrease.circle"
203 }
204 size={16}
205 tintColor={iconColor}
206 type="monochrome"
207 />
208 </Pressable>
209 </ControlPillMenu>
210 {/* Built identically to the filter button so the two circles
211 match exactly (ControlPill sizes via Tailwind classes and
212 resolves to a different box). */}
213 <Pressable
214 accessibilityLabel="Open settings"
215 accessibilityRole="button"
216 onPress={props.onOpenSettings}
217 className="size-11 items-center justify-center rounded-full bg-subtle"
218 >
219 <SymbolView name="gearshape" size={18} tintColor={iconColor} type="monochrome" />
220 </Pressable>
221 </View>
222
223 <View className="min-h-12 flex-row items-center gap-2.5 rounded-2xl border border-input-border bg-input px-3.5">
224 <SymbolView name="magnifyingglass" size={17} tintColor={mutedColor} type="monochrome" />
225 <TextInput
226 accessibilityLabel="Search threads"
227 autoCapitalize="none"
228 onChangeText={props.onSearchQueryChange}
229 placeholder="Search threads"
230 placeholderTextColorClassName="accent-placeholder"
231 className="flex-1 py-2.5 text-base font-sans text-foreground"
232 value={props.searchQuery}
233 />
234 {props.searchQuery.length > 0 ? (
235 <Pressable
236 accessibilityLabel="Clear search"
237 hitSlop={10}
238 onPress={() => props.onSearchQueryChange("")}
239 >
240 <SymbolView
241 name="xmark.circle.fill"
242 size={17}
243 tintColor={mutedColor}
244 type="monochrome"
245 />
246 </Pressable>
247 ) : null}
248 </View>
249 </View>
250 </View>
251 </>
252 );
253}
254
255function IosHomeHeader(props: HomeHeaderProps) {
45 const searchBarRef = useRef<SearchBarCommands>(null);256 const searchBarRef = useRef<SearchBarCommands>(null);
46 const iconColor = useThemeColor("--color-icon");257 const iconColor = useThemeColor("--color-icon");
47 const hasCustomListOptions = hasCustomHomeListOptions(props);258 const hasCustomListOptions = hasCustomHomeListOptions(props);
apps/mobile/src/features/home/HomeRouteScreen.tsx+70-64
...@@ -11,6 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re...@@ -11,6 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
11import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";11import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
12import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";12import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
13import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";13import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
14import { AndroidHomeFabLayout } from "./AndroidHomeFab";
14import { HomeScreen } from "./HomeScreen";15import { HomeScreen } from "./HomeScreen";
15import { HomeHeader } from "./HomeHeader";16import { HomeHeader } from "./HomeHeader";
16import { useHomeListOptions } from "./home-list-options";17import { useHomeListOptions } from "./home-list-options";
...@@ -80,70 +81,75 @@ export function HomeRouteScreen() {...@@ -80,70 +81,75 @@ export function HomeRouteScreen() {
80 }81 }
8182
82 return (83 return (
83 <>84 <AndroidHomeFabLayout
84 {/* Restore the compact title in case the split branch blanked it. */}85 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
85 <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />86 >
86 <HomeHeader87 <>
87 environments={environments}88 {/* Restore the compact title in case the split branch blanked it. */}
88 selectedEnvironmentId={selectedEnvironmentId}89 <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />
89 projectSortOrder={listOptions.projectSortOrder}90 <HomeHeader
90 threadSortOrder={listOptions.threadSortOrder}91 environments={environments}
91 projectGroupingMode={listOptions.projectGroupingMode}92 searchQuery={searchQuery}
92 onEnvironmentChange={setSelectedEnvironmentId}93 selectedEnvironmentId={selectedEnvironmentId}
93 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}94 projectSortOrder={listOptions.projectSortOrder}
94 onProjectGroupingModeChange={setProjectGroupingMode}95 threadSortOrder={listOptions.threadSortOrder}
95 onProjectSortOrderChange={setProjectSortOrder}96 projectGroupingMode={listOptions.projectGroupingMode}
96 onSearchQueryChange={setSearchQuery}97 onEnvironmentChange={setSelectedEnvironmentId}
97 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}98 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
98 onThreadSortOrderChange={setThreadSortOrder}99 onProjectGroupingModeChange={setProjectGroupingMode}
99 />100 onProjectSortOrderChange={setProjectSortOrder}
101 onSearchQueryChange={setSearchQuery}
102 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
103 onThreadSortOrderChange={setThreadSortOrder}
104 />
100105
101 <HomeScreen106 <HomeScreen
102 catalogState={catalogState}107 catalogState={catalogState}
103 environments={environments}108 environments={environments}
104 onAddConnection={() =>109 onAddConnection={() =>
105 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })110 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })
106 }111 }
107 onArchiveThread={archiveThread}112 onArchiveThread={archiveThread}
108 onDeleteThread={confirmDeleteThread}113 onDeleteThread={confirmDeleteThread}
109 onEnvironmentChange={setSelectedEnvironmentId}114 onEnvironmentChange={setSelectedEnvironmentId}
110 onOpenEnvironments={() =>115 onOpenEnvironments={() =>
111 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })116 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
112 }117 }
113 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}118 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
114 onProjectGroupingModeChange={setProjectGroupingMode}119 onProjectGroupingModeChange={setProjectGroupingMode}
115 onProjectSortOrderChange={setProjectSortOrder}120 onProjectSortOrderChange={setProjectSortOrder}
116 onSearchQueryChange={setSearchQuery}121 onSearchQueryChange={setSearchQuery}
117 onSelectThread={(thread) => {122 onSelectThread={(thread) => {
118 navigation.navigate("Thread", {123 navigation.navigate("Thread", {
119 environmentId: thread.environmentId,124 environmentId: thread.environmentId,
120 threadId: thread.id,125 threadId: thread.id,
121 });126 });
122 }}127 }}
123 onSelectPendingTask={openPendingTask}128 onSelectPendingTask={openPendingTask}
124 onDeletePendingTask={confirmDeletePendingTask}129 onDeletePendingTask={confirmDeletePendingTask}
125 onNewThreadInProject={(project) => {130 onNewThreadInProject={(project) => {
126 navigation.navigate("NewTaskSheet", {131 navigation.navigate("NewTaskSheet", {
127 screen: "NewTaskDraft",132 screen: "NewTaskDraft",
128 params: {133 params: {
129 environmentId: String(project.environmentId),134 environmentId: String(project.environmentId),
130 projectId: String(project.id),135 projectId: String(project.id),
131 title: project.title,136 title: project.title,
132 },137 },
133 });138 });
134 }}139 }}
135 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}140 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
136 onThreadSortOrderChange={setThreadSortOrder}141 onThreadSortOrderChange={setThreadSortOrder}
137 pendingTasks={pendingTasks}142 pendingTasks={pendingTasks}
138 projectGroupingMode={listOptions.projectGroupingMode}143 projectGroupingMode={listOptions.projectGroupingMode}
139 projects={projects}144 projects={projects}
140 projectSortOrder={listOptions.projectSortOrder}145 projectSortOrder={listOptions.projectSortOrder}
141 savedConnectionsById={savedConnectionsById}146 savedConnectionsById={savedConnectionsById}
142 searchQuery={searchQuery}147 searchQuery={searchQuery}
143 selectedEnvironmentId={selectedEnvironmentId}148 selectedEnvironmentId={selectedEnvironmentId}
144 threads={threads}149 threads={threads}
145 threadSortOrder={listOptions.threadSortOrder}150 threadSortOrder={listOptions.threadSortOrder}
146 />151 />
147 </>152 </>
153 </AndroidHomeFabLayout>
148 );154 );
149}155}
apps/mobile/src/features/home/HomeScreen.tsx+16-7
...@@ -81,8 +81,12 @@ interface HomeScreenProps {...@@ -81,8 +81,12 @@ interface HomeScreenProps {
81/* ─── Layout constants ───────────────────────────────────────────────── */81/* ─── Layout constants ───────────────────────────────────────────────── */
8282
83const ESTIMATED_THREAD_ROW_HEIGHT = 72;83const ESTIMATED_THREAD_ROW_HEIGHT = 72;
84/** Height of the floating custom header on non-iOS platforms. */84/**
85const CUSTOM_HEADER_HEIGHT = 78;85 * Top spacing between the list and the Android custom header. The Android
86 * header (AndroidHomeHeader) is rendered in-flow above this screen and
87 * already consumes the top safe-area inset, so the list only needs breathing
88 * room here.
89 */
8690
87function deriveEmptyState(props: {91function deriveEmptyState(props: {
88 readonly catalogState: WorkspaceState;92 readonly catalogState: WorkspaceState;
...@@ -147,8 +151,8 @@ function deriveEmptyState(props: {...@@ -147,8 +151,8 @@ function deriveEmptyState(props: {
147 };151 };
148}152}
149153
150function HomeTopContentSpacer(props: { readonly topInset: number }) {154function HomeTopContentSpacer() {
151 return <View style={{ height: props.topInset + CUSTOM_HEADER_HEIGHT }} />;155 return <View className="h-4" />;
152}156}
153157
154/* ─── Main screen ────────────────────────────────────────────────────── */158/* ─── Main screen ────────────────────────────────────────────────────── */
...@@ -385,7 +389,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -385,7 +389,7 @@ export function HomeScreen(props: HomeScreenProps) {
385 className="flex-1 items-center justify-center bg-screen px-8"389 className="flex-1 items-center justify-center bg-screen px-8"
386 style={{390 style={{
387 paddingBottom: Math.max(insets.bottom, 24),391 paddingBottom: Math.max(insets.bottom, 24),
388 paddingTop: Platform.OS === "ios" ? insets.top + 72 : insets.top,392 paddingTop: Platform.OS === "ios" ? insets.top + 72 : 0,
389 }}393 }}
390 >394 >
391 <View className="w-full max-w-[430px]">395 <View className="w-full max-w-[430px]">
...@@ -409,7 +413,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -409,7 +413,7 @@ export function HomeScreen(props: HomeScreenProps) {
409413
410 const listHeader = (414 const listHeader = (
411 <>415 <>
412 {Platform.OS === "ios" ? null : <HomeTopContentSpacer topInset={insets.top} />}416 {Platform.OS === "ios" ? null : <HomeTopContentSpacer />}
413417
414 {shouldShowConnectionStatus && Platform.OS === "ios" ? (418 {shouldShowConnectionStatus && Platform.OS === "ios" ? (
415 <View className="pb-4">419 <View className="pb-4">
...@@ -466,7 +470,12 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -466,7 +470,12 @@ export function HomeScreen(props: HomeScreenProps) {
466 recycleItems470 recycleItems
467 scrollEventThrottle={16}471 scrollEventThrottle={16}
468 contentContainerStyle={{472 contentContainerStyle={{
469 paddingBottom: Platform.OS === "ios" ? Math.max(insets.bottom, 24) + 24 : 24,473 // Android reserves room for the floating new-task FAB
474 // (56 button + 16 gap + bottom inset).
475 paddingBottom:
476 Platform.OS === "ios"
477 ? Math.max(insets.bottom, 24) + 24
478 : Math.max(insets.bottom, 16) + 88,
470 }}479 }}
471 scrollIndicatorInsets={480 scrollIndicatorInsets={
472 Platform.OS === "ios"481 Platform.OS === "ios"
apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../../components/AppSymbol";
2import { ActivityIndicator, Pressable } from "react-native";2import { ActivityIndicator, Pressable } from "react-native";
33
4import { AppText as Text } from "../../components/AppText";4import { AppText as Text } from "../../components/AppText";
apps/mobile/src/features/home/thread-swipe-actions.tsx+7-4
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../../components/AppSymbol";
2import * as Haptics from "expo-haptics";2import * as Haptics from "expo-haptics";
3import {3import {
4 createContext,4 createContext,
...@@ -32,7 +32,8 @@ import Animated, {...@@ -32,7 +32,8 @@ import Animated, {
3232
33import { AppText as Text } from "../../components/AppText";33import { AppText as Text } from "../../components/AppText";
3434
35const ACTION_ITEM_WIDTH = 50;35// Wide enough for the longest action label ("Unarchive").
36const ACTION_ITEM_WIDTH = 58;
36const ACTION_CIRCLE_SIZE = 36;37const ACTION_CIRCLE_SIZE = 36;
37const ACTION_ICON_SIZE = 15;38const ACTION_ICON_SIZE = 15;
3839
...@@ -196,7 +197,7 @@ export function ThreadSwipeable(props: {...@@ -196,7 +197,7 @@ export function ThreadSwipeable(props: {
196 swipeableRef.current?.reset();197 swipeableRef.current?.reset();
197 }, [resetKey]);198 }, [resetKey]);
198 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {199 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
199 if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {200 if (armed && !fullSwipeArmedRef.current) {
200 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);201 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
201 }202 }
202 fullSwipeArmedRef.current = armed;203 fullSwipeArmedRef.current = armed;
...@@ -410,7 +411,9 @@ function SwipeActionButton(props: {...@@ -410,7 +411,9 @@ function SwipeActionButton(props: {
410 </Animated.View>411 </Animated.View>
411 </View>412 </View>
412 <Animated.View style={[{ paddingTop: 2 }, labelStyle]}>413 <Animated.View style={[{ paddingTop: 2 }, labelStyle]}>
413 <Text className="text-3xs font-t3-medium text-foreground-muted">{props.label}</Text>414 <Text className="text-3xs font-t3-medium text-foreground-muted" numberOfLines={1}>
415 {props.label}
416 </Text>
414 </Animated.View>417 </Animated.View>
415 </Pressable>418 </Pressable>
416 </Animated.View>419 </Animated.View>
apps/mobile/src/features/home/useThreadListActions.ts+18-9
...@@ -4,6 +4,7 @@ import * as Haptics from "expo-haptics";...@@ -4,6 +4,7 @@ import * as Haptics from "expo-haptics";
4import { useCallback, useRef } from "react";4import { useCallback, useRef } from "react";
5import { Alert } from "react-native";5import { Alert } from "react-native";
66
7import { showConfirmDialog } from "../../components/ConfirmDialogHost";
7import { scopedThreadKey } from "../../lib/scopedEntities";8import { scopedThreadKey } from "../../lib/scopedEntities";
8import { threadEnvironment } from "../../state/threads";9import { threadEnvironment } from "../../state/threads";
9import { useAtomCommand } from "../../state/use-atom-command";10import { useAtomCommand } from "../../state/use-atom-command";
...@@ -21,9 +22,7 @@ function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unkno...@@ -21,9 +22,7 @@ function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unkno
21}22}
2223
23function selectionHaptic(): void {24function selectionHaptic(): void {
24 if (process.env.EXPO_OS === "ios") {25 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
25 void Haptics.selectionAsync();
26 }
27}26}
2827
29function actionFailureTitle(action: ThreadListAction): string {28function actionFailureTitle(action: ThreadListAction): string {
...@@ -80,10 +79,10 @@ function useConfirmDeleteThread(...@@ -80,10 +79,10 @@ function useConfirmDeleteThread(
80) {79) {
81 return useCallback(80 return useCallback(
82 (thread: EnvironmentThreadShell) => {81 (thread: EnvironmentThreadShell) => {
83 Alert.alert(82 const title = "Delete thread?";
84 "Delete thread?",83 const message = `“${thread.title}” will be permanently deleted, including its terminal history.`;
85 `“${thread.title}” will be permanently deleted, including its terminal history.`,84 if (process.env.EXPO_OS === "ios") {
86 [85 Alert.alert(title, message, [
87 { text: "Cancel", style: "cancel" },86 { text: "Cancel", style: "cancel" },
88 {87 {
89 text: "Delete",88 text: "Delete",
...@@ -92,8 +91,18 @@ function useConfirmDeleteThread(...@@ -92,8 +91,18 @@ function useConfirmDeleteThread(
92 void executeAction("delete", thread);91 void executeAction("delete", thread);
93 },92 },
94 },93 },
95 ],94 ]);
96 );95 return;
96 }
97 showConfirmDialog({
98 title,
99 message,
100 confirmText: "Delete",
101 destructive: true,
102 onConfirm: () => {
103 void executeAction("delete", thread);
104 },
105 });
97 },106 },
98 [executeAction],107 [executeAction],
99 );108 );
apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../../components/AppSymbol";
2import { Pressable, View } from "react-native";2import { Pressable, View } from "react-native";
33
4import { AppText as Text } from "../../components/AppText";4import { AppText as Text } from "../../components/AppText";
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+2-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import type { ReactNode } from "react";2import type { ReactNode } from "react";
3import { Platform } from "react-native";
34
4import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout";5import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout";
56
...@@ -11,7 +12,7 @@ export function WorkspaceSidebarToolbar(...@@ -11,7 +12,7 @@ export function WorkspaceSidebarToolbar(
11) {12) {
12 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();13 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
1314
14 if (!layout.usesSplitView) {15 if (Platform.OS === "android" || !layout.usesSplitView) {
15 return null;16 return null;
16 }17 }
1718
apps/mobile/src/features/projects/AddProjectScreen.tsx+1-1
...@@ -23,7 +23,7 @@ import {...@@ -23,7 +23,7 @@ import {
23} from "@t3tools/client-runtime/state/projects";23} from "@t3tools/client-runtime/state/projects";
24import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";24import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
25import { StackActions, useNavigation } from "@react-navigation/native";25import { StackActions, useNavigation } from "@react-navigation/native";
26import { SymbolView } from "expo-symbols";26import { SymbolView } from "../../components/AppSymbol";
27import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";27import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
28import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";28import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
29import { useSafeAreaInsets } from "react-native-safe-area-context";29import { useSafeAreaInsets } from "react-native-safe-area-context";
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+61-30
...@@ -1,14 +1,21 @@...@@ -1,14 +1,21 @@
1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { SymbolView } from "expo-symbols";
3import { TextInputWrapper } from "expo-paste-input";2import { TextInputWrapper } from "expo-paste-input";
4import type { EnvironmentId, ThreadId } from "@t3tools/contracts";3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
5import { useCallback, useEffect, useMemo, useState } from "react";4import { useCallback, useEffect, useMemo, useState } from "react";
6import { Pressable, ScrollView, View, useColorScheme, useWindowDimensions } from "react-native";5import {
7import { KeyboardAvoidingView } from "react-native-keyboard-controller";6 Platform,
7 Pressable,
8 ScrollView,
9 View,
10 useColorScheme,
11 useWindowDimensions,
12} from "react-native";
13import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
8import { useSafeAreaInsets } from "react-native-safe-area-context";14import { useSafeAreaInsets } from "react-native-safe-area-context";
9import ImageViewing from "react-native-image-viewing";15import ImageViewing from "react-native-image-viewing";
1016
11import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";17import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
18import { SymbolView } from "../../components/AppSymbol";
12import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";19import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
13import { ControlPill } from "../../components/ControlPill";20import { ControlPill } from "../../components/ControlPill";
14import { cn } from "../../lib/cn";21import { cn } from "../../lib/cn";
...@@ -26,12 +33,7 @@ import {...@@ -26,12 +33,7 @@ import {
26 useReviewCommentTarget,33 useReviewCommentTarget,
27} from "./reviewCommentSelection";34} from "./reviewCommentSelection";
28import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";35import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
29import {36import { changeTone, DiffTokenText, ReviewChangeBar } from "./reviewDiffRendering";
30 changeTone,
31 DiffTokenText,
32 REVIEW_MONO_FONT_FAMILY,
33 ReviewChangeBar,
34} from "./reviewDiffRendering";
35import {37import {
36 highlightReviewSelectedLines,38 highlightReviewSelectedLines,
37 type ReviewDiffTheme,39 type ReviewDiffTheme,
...@@ -46,6 +48,7 @@ type ReviewCommentComposerSheetProps = StaticScreenProps<{...@@ -46,6 +48,7 @@ type ReviewCommentComposerSheetProps = StaticScreenProps<{
46}>;48}>;
4749
48export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) {50export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) {
51 const isAndroid = Platform.OS === "android";
49 const navigation = useNavigation();52 const navigation = useNavigation();
50 const insets = useSafeAreaInsets();53 const insets = useSafeAreaInsets();
51 const { width } = useWindowDimensions();54 const { width } = useWindowDimensions();
...@@ -143,12 +146,30 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp...@@ -143,12 +146,30 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
143 }146 }
144 }147 }
145148
149 const handleSubmit = useCallback(() => {
150 if (!target || !environmentId || !threadId || commentText.trim().length === 0) {
151 return;
152 }
153
154 appendReviewCommentToDraft({
155 environmentId,
156 threadId,
157 text: formatReviewCommentContext(target, commentText),
158 attachments,
159 });
160 setAttachments([]);
161 dismissComposer();
162 }, [attachments, commentText, dismissComposer, environmentId, target, threadId]);
163
146 return (164 return (
147 <View className="flex-1">165 <View className="flex-1">
148 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">166 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">
149 <View167 <View
150 className="flex-1 px-5 pt-2"168 className="flex-1 px-5"
151 style={{ paddingBottom: target ? 0 : Math.max(insets.bottom, 18) }}169 style={{
170 paddingTop: isAndroid ? insets.top + 8 : 8,
171 paddingBottom: target ? (isAndroid ? 72 : 0) : Math.max(insets.bottom, 18),
172 }}
152 >173 >
153 <View className="flex-row items-center justify-between py-2">174 <View className="flex-row items-center justify-between py-2">
154 <Pressable175 <Pressable
...@@ -213,10 +234,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp...@@ -213,10 +234,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
213 style={{ height: codeSurface.rowHeight }}234 style={{ height: codeSurface.rowHeight }}
214 >235 >
215 <ReviewChangeBar change={line.change} height={codeSurface.rowHeight} />236 <ReviewChangeBar change={line.change} height={codeSurface.rowHeight} />
216 <Text237 <Text className="w-9 py-1 pr-1 text-right text-2xs font-mono text-foreground-muted">
217 className="w-9 py-1 pr-1 text-right text-2xs font-t3-medium text-foreground-muted"
218 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
219 >
220 {lineNumber ?? ""}238 {lineNumber ?? ""}
221 </Text>239 </Text>
222 <View className="min-w-0 flex-1 shrink-0 px-1 py-1">240 <View className="min-w-0 flex-1 shrink-0 px-1 py-1">
...@@ -274,7 +292,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp...@@ -274,7 +292,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
274 </View>292 </View>
275 )}293 )}
276 </View>294 </View>
277 {target ? (295 {!isAndroid && target ? (
278 <View className="flex-row items-center gap-3 bg-sheet px-5 py-2">296 <View className="flex-row items-center gap-3 bg-sheet px-5 py-2">
279 <ControlPill297 <ControlPill
280 accessibilityLabel="Add image"298 accessibilityLabel="Add image"
...@@ -288,24 +306,37 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp...@@ -288,24 +306,37 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
288 label="Comment"306 label="Comment"
289 variant="primary"307 variant="primary"
290 disabled={!canSubmit}308 disabled={!canSubmit}
291 onPress={() => {309 onPress={handleSubmit}
292 if (!target || !environmentId || !threadId || commentText.trim().length === 0) {
293 return;
294 }
295
296 appendReviewCommentToDraft({
297 environmentId,
298 threadId,
299 text: formatReviewCommentContext(target, commentText),
300 attachments,
301 });
302 setAttachments([]);
303 dismissComposer();
304 }}
305 />310 />
306 </View>311 </View>
307 ) : null}312 ) : null}
308 </KeyboardAvoidingView>313 </KeyboardAvoidingView>
314 {isAndroid && target ? (
315 <KeyboardStickyView
316 className="absolute inset-x-0 bottom-0"
317 offset={{ closed: 0, opened: 0 }}
318 >
319 <View
320 className="flex-row items-center gap-3 border-t border-border bg-sheet px-5 pt-2"
321 style={{ paddingBottom: Math.max(insets.bottom, 10) }}
322 >
323 <ControlPill
324 accessibilityLabel="Add image"
325 icon="plus"
326 onPress={() => void handlePickImages()}
327 />
328 <View className="flex-1" />
329 <ControlPill
330 accessibilityLabel="Comment"
331 icon="arrow.up"
332 label="Comment"
333 variant="primary"
334 disabled={!canSubmit}
335 onPress={handleSubmit}
336 />
337 </View>
338 </KeyboardStickyView>
339 ) : null}
309 <ImageViewing340 <ImageViewing
310 images={previewImageUri ? [{ uri: previewImageUri }] : []}341 images={previewImageUri ? [{ uri: previewImageUri }] : []}
311 imageIndex={0}342 imageIndex={0}
apps/mobile/src/features/review/ReviewSheet.tsx+99-13
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import type { MenuAction } from "@react-native-menu/menu";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";3import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import {4import {
4 NativeHeaderToolbar,5 NativeHeaderToolbar,
...@@ -6,7 +7,6 @@ import {...@@ -6,7 +7,6 @@ import {
6 nativeHeaderScrollEdgeEffects,7 nativeHeaderScrollEdgeEffects,
7} from "../../native/StackHeader";8} from "../../native/StackHeader";
8import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";9import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
9import { SymbolView } from "expo-symbols";
10import {10import {
11 memo,11 memo,
12 type Ref,12 type Ref,
...@@ -32,6 +32,9 @@ import {...@@ -32,6 +32,9 @@ import {
32import { useSafeAreaInsets } from "react-native-safe-area-context";32import { useSafeAreaInsets } from "react-native-safe-area-context";
3333
34import { AppText as Text } from "../../components/AppText";34import { AppText as Text } from "../../components/AppText";
35import { SymbolView } from "../../components/AppSymbol";
36import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
37import { ControlPillMenu } from "../../components/ControlPill";
35import { environmentCatalog } from "../../connection/catalog";38import { environmentCatalog } from "../../connection/catalog";
36import { useEnvironmentPresentation } from "../../state/presentation";39import { useEnvironmentPresentation } from "../../state/presentation";
37import { useAtomCommand } from "../../state/use-atom-command";40import { useAtomCommand } from "../../state/use-atom-command";
...@@ -67,6 +70,7 @@ import { useReviewCommentSelectionController } from "./useReviewCommentSelection...@@ -67,6 +70,7 @@ import { useReviewCommentSelectionController } from "./useReviewCommentSelection
67import { resolveReviewAvailability } from "./reviewAvailability";70import { resolveReviewAvailability } from "./reviewAvailability";
68import { resolveSelectedReviewFileId } from "./reviewPaneSelection";71import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
69import { buildReviewSectionMenu } from "./review-section-menu";72import { buildReviewSectionMenu } from "./review-section-menu";
73import type { ReviewSectionItem } from "./reviewModel";
7074
71const REVIEW_HEADER_SPACING = 0;75const REVIEW_HEADER_SPACING = 0;
7276
...@@ -330,6 +334,7 @@ type ReviewSheetProps = StaticScreenProps<{...@@ -330,6 +334,7 @@ type ReviewSheetProps = StaticScreenProps<{
330}>;334}>;
331335
332export function ReviewSheet(props: ReviewSheetProps) {336export function ReviewSheet(props: ReviewSheetProps) {
337 const isAndroid = Platform.OS === "android";
333 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();338 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
334 useAdaptiveWorkspacePaneRole("inspector");339 useAdaptiveWorkspacePaneRole("inspector");
335 const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();340 const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
...@@ -362,7 +367,8 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -362,7 +367,8 @@ export function ReviewSheet(props: ReviewSheetProps) {
362 selectedThread !== null && String(selectedThread.id) === String(threadId);367 selectedThread !== null && String(selectedThread.id) === String(threadId);
363 const selectedTheme = colorScheme === "dark" ? "dark" : "light";368 const selectedTheme = colorScheme === "dark" ? "dark" : "light";
364 // With a solid (non-overlay) header the content lays out below the header369 // With a solid (non-overlay) header the content lays out below the header
365 // natively, so no manual top inset is needed.370 // natively, so no manual top inset is needed. (Android renders its own
371 // in-flow AndroidScreenHeader, so it needs no inset either.)
366 const topContentInset = 0;372 const topContentInset = 0;
367373
368 useEffect(() => {374 useEffect(() => {
...@@ -498,6 +504,52 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -498,6 +504,52 @@ export function ReviewSheet(props: ReviewSheetProps) {
498 hasCachedSelectedDiff,504 hasCachedSelectedDiff,
499 hasAnyCachedDiff,505 hasAnyCachedDiff,
500 });506 });
507 const androidSectionMenuActions = useMemo<MenuAction[]>(() => {
508 const sectionAction = (section: ReviewSectionItem | null, title: string): MenuAction => ({
509 id: section ? `section:${section.id}` : `unavailable:${title}`,
510 title: section?.id === selectedSection?.id ? `${title} (selected)` : title,
511 attributes: section ? undefined : { disabled: true },
512 });
513 const actions: MenuAction[] = [
514 sectionAction(sectionMenu.workingTree, "Working tree"),
515 sectionAction(sectionMenu.branchChanges, "Branch changes"),
516 sectionAction(sectionMenu.latestTurn, "Latest turn"),
517 ];
518
519 if (sectionMenu.turns.length > 0) {
520 actions.push({
521 id: "turns",
522 title: "Turn",
523 subactions: sectionMenu.turns.map((section) => ({
524 id: `section:${section.id}`,
525 title: section.id === selectedSection?.id ? `${section.title} (selected)` : section.title,
526 subtitle: section.subtitle ?? undefined,
527 })),
528 });
529 }
530
531 // The Android native diff surface has no pull-to-refresh, so refresh
532 // stays a menu action there (iOS refreshes via pull-to-refresh instead).
533 actions.push({
534 id: "refresh",
535 title: "Refresh current diff",
536 attributes: {
537 disabled: !selectedSection || selectedSection.isLoading,
538 },
539 });
540 return actions;
541 }, [sectionMenu, selectedSection]);
542 const handleAndroidSectionMenuAction = useCallback(
543 (event: { nativeEvent: { event: string } }) => {
544 const id = event.nativeEvent.event;
545 if (id === "refresh") {
546 void refreshSelectedSection();
547 } else if (id.startsWith("section:")) {
548 selectSection(id.slice("section:".length));
549 }
550 },
551 [refreshSelectedSection, selectSection],
552 );
501 const handleRetryEnvironment = useCallback(() => {553 const handleRetryEnvironment = useCallback(() => {
502 void retryEnvironment(environmentId);554 void retryEnvironment(environmentId);
503 }, [environmentId, retryEnvironment]);555 }, [environmentId, retryEnvironment]);
...@@ -511,6 +563,13 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -511,6 +563,13 @@ export function ReviewSheet(props: ReviewSheetProps) {
511 threadId: String(threadId),563 threadId: String(threadId),
512 });564 });
513 }, [environmentId, navigation, threadId]);565 }, [environmentId, navigation, threadId]);
566 const androidHeaderSubtitle = [
567 selectedSection?.title,
568 headerDiffSummary.additions,
569 headerDiffSummary.deletions,
570 ]
571 .filter((part): part is string => Boolean(part))
572 .join(" · ");
514573
515 // The changed-files navigator lives in the workspace inspector column —574 // The changed-files navigator lives in the workspace inspector column —
516 // the single right-hand pane per route — instead of an in-screen panel.575 // the single right-hand pane per route — instead of an in-screen panel.
...@@ -554,18 +613,45 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -554,18 +613,45 @@ export function ReviewSheet(props: ReviewSheetProps) {
554 return (613 return (
555 <>614 <>
556 <NativeStackScreenOptions615 <NativeStackScreenOptions
557 options={{616 options={
558 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native617 isAndroid
559 // diff scrolls internally, nothing for glass to sample). Only dynamic values618 ? // Android draws its own in-flow header (AndroidScreenHeader below).
560 // here.619 { headerShown: false }
561 headerTintColor: headerIcon,620 : {
562 headerTitle: headerTitleText,621 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native
563 title: headerTitleText,622 // diff scrolls internally, nothing for glass to sample). Only dynamic values
564 unstable_headerSubtitle:623 // here.
565 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,624 headerTintColor: headerIcon,
566 }}625 headerTitle: headerTitleText,
626 title: headerTitleText,
627 unstable_headerSubtitle:
628 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
629 }
630 }
567 />631 />
568632
633 {isAndroid ? (
634 <AndroidScreenHeader
635 title="Review changes"
636 subtitle={androidHeaderSubtitle || "Select a diff"}
637 onBack={handleReturnToThread}
638 trailing={
639 showSectionToolbar ? (
640 <ControlPillMenu
641 actions={androidSectionMenuActions}
642 isAnchoredToRight
643 onPressAction={handleAndroidSectionMenuAction}
644 >
645 <AndroidHeaderIconButton
646 accessibilityLabel="Select review diff"
647 icon="ellipsis.circle"
648 />
649 </ControlPillMenu>
650 ) : null
651 }
652 />
653 ) : null}
654
569 <WorkspaceSidebarToolbar>655 <WorkspaceSidebarToolbar>
570 <NativeHeaderToolbar.Button656 <NativeHeaderToolbar.Button
571 accessibilityLabel="Back to chat"657 accessibilityLabel="Back to chat"
...@@ -574,7 +660,7 @@ export function ReviewSheet(props: ReviewSheetProps) {...@@ -574,7 +660,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
574 />660 />
575 </WorkspaceSidebarToolbar>661 </WorkspaceSidebarToolbar>
576662
577 {showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable ? (663 {!isAndroid && (showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable) ? (
578 <NativeHeaderToolbar placement="right">664 <NativeHeaderToolbar placement="right">
579 {panes.supportsAuxiliaryPane ? (665 {panes.supportsAuxiliaryPane ? (
580 <NativeHeaderToolbar.Button666 <NativeHeaderToolbar.Button
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+33-11
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { useNavigation } from "@react-navigation/native";2import { useNavigation } from "@react-navigation/native";
3import { SymbolView } from "expo-symbols";3import { SymbolView } from "../../components/AppSymbol";
4import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
5import { useCallback, useState } from "react";5import { useCallback, useState } from "react";
6import { ScrollView, View } from "react-native";6import { Platform, ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
88
9import { AppText as Text } from "../../components/AppText";9import { AppText as Text } from "../../components/AppText";
10import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
10import { hasCloudPublicConfig } from "../cloud/publicConfig";11import { hasCloudPublicConfig } from "../cloud/publicConfig";
11import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";12import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
12import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";13import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
...@@ -39,14 +40,35 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -39,14 +40,35 @@ export function SettingsEnvironmentsRouteScreen() {
3940
40 return (41 return (
41 <View collapsable={false} className="flex-1 bg-sheet">42 <View collapsable={false} className="flex-1 bg-sheet">
42 <NativeHeaderToolbar placement="right">43 {Platform.OS === "android" ? (
43 <NativeHeaderToolbar.Button44 <>
44 icon="plus"45 {/* Android renders its own in-screen header instead of the native bar. */}
45 onPress={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })}46 <NativeStackScreenOptions options={{ headerShown: false }} />
46 separateBackground47 <AndroidScreenHeader
47 tintColor={headerIconColor}48 title="Environments"
48 />49 onBack={() => navigation.goBack()}
49 </NativeHeaderToolbar>50 actions={[
51 {
52 accessibilityLabel: "Add environment",
53 icon: "plus",
54 onPress: () =>
55 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }),
56 },
57 ]}
58 />
59 </>
60 ) : (
61 <NativeHeaderToolbar placement="right">
62 <NativeHeaderToolbar.Button
63 icon="plus"
64 onPress={() =>
65 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })
66 }
67 separateBackground
68 tintColor={headerIconColor}
69 />
70 </NativeHeaderToolbar>
71 )}
50 <ScrollView72 <ScrollView
51 contentInsetAdjustmentBehavior="automatic"73 contentInsetAdjustmentBehavior="automatic"
52 showsVerticalScrollIndicator={false}74 showsVerticalScrollIndicator={false}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+42-21
...@@ -5,7 +5,7 @@ import * as Notifications from "expo-notifications";...@@ -5,7 +5,7 @@ import * as Notifications from "expo-notifications";
5import * as Updates from "expo-updates";5import * as Updates from "expo-updates";
6import { useNavigation } from "@react-navigation/native";6import { useNavigation } from "@react-navigation/native";
7import { NativeStackScreenOptions } from "../../native/StackHeader";7import { NativeStackScreenOptions } from "../../native/StackHeader";
8import { SymbolView } from "expo-symbols";8import { SymbolView } from "../../components/AppSymbol";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import { AsyncResult } from "effect/unstable/reactivity";10import { AsyncResult } from "effect/unstable/reactivity";
11import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";11import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
...@@ -19,7 +19,9 @@ import {...@@ -19,7 +19,9 @@ import {
19 settlePromise,19 settlePromise,
20 squashAtomCommandFailure,20 squashAtomCommandFailure,
21} from "@t3tools/client-runtime/state/runtime";21} from "@t3tools/client-runtime/state/runtime";
22import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
22import { AppText as Text } from "../../components/AppText";23import { AppText as Text } from "../../components/AppText";
24import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";
23import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";25import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
24import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";26import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions";
25import {27import {
...@@ -62,23 +64,31 @@ export function SettingsRouteScreen() {...@@ -62,23 +64,31 @@ export function SettingsRouteScreen() {
62 return (64 return (
63 <>65 <>
64 <WorkspaceSidebarToolbar />66 <WorkspaceSidebarToolbar />
65 <NativeStackScreenOptions67 {Platform.OS === "android" ? (
66 options={{68 <>
67 unstable_headerRightItems:69 {/* Android renders its own in-screen header instead of the native bar. */}
68 Platform.OS === "ios"70 <NativeStackScreenOptions options={{ headerShown: false }} />
69 ? () => [71 <AndroidScreenHeader title="Settings" onBack={() => navigation.goBack()} />
70 withNativeGlassHeaderItem({72 </>
71 accessibilityLabel: "Close settings",73 ) : (
72 icon: { name: "xmark", type: "sfSymbol" } as const,74 <NativeStackScreenOptions
73 identifier: "settings-close",75 options={{
74 label: "",76 unstable_headerRightItems:
75 onPress: () => navigation.goBack(),77 Platform.OS === "ios"
76 type: "button",78 ? () => [
77 }),79 withNativeGlassHeaderItem({
78 ]80 accessibilityLabel: "Close settings",
79 : undefined,81 icon: { name: "xmark", type: "sfSymbol" } as const,
80 }}82 identifier: "settings-close",
81 />83 label: "",
84 onPress: () => navigation.goBack(),
85 type: "button",
86 }),
87 ]
88 : undefined,
89 }}
90 />
91 )}
82 {hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />}92 {hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />}
83 </>93 </>
84 );94 );
...@@ -124,6 +134,7 @@ function LocalSettingsRouteScreen() {...@@ -124,6 +134,7 @@ function LocalSettingsRouteScreen() {
124function ConfiguredSettingsRouteScreen() {134function ConfiguredSettingsRouteScreen() {
125 const preferencesResult = useAtomValue(mobilePreferencesAtom);135 const preferencesResult = useAtomValue(mobilePreferencesAtom);
126 const savePreferences = useAtomSet(updateMobilePreferencesAtom);136 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
137 const agentAwarenessPushAvailable = supportsAgentAwarenessPush();
127 const insets = useSafeAreaInsets();138 const insets = useSafeAreaInsets();
128 const navigation = useNavigation();139 const navigation = useNavigation();
129 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();140 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();
...@@ -458,22 +469,32 @@ function ConfiguredSettingsRouteScreen() {...@@ -458,22 +469,32 @@ function ConfiguredSettingsRouteScreen() {
458 <SettingsSwitchRow469 <SettingsSwitchRow
459 icon="bell.badge"470 icon="bell.badge"
460 label="Device Notifications"471 label="Device Notifications"
461 disabled={notificationStatus === "checking" || notificationStatus === "unsupported"}472 disabled={
473 !agentAwarenessPushAvailable ||
474 notificationStatus === "checking" ||
475 notificationStatus === "unsupported"
476 }
462 // Only reads as on when this device is actually registered with the477 // Only reads as on when this device is actually registered with the
463 // relay; otherwise notifications cannot be delivered regardless of478 // relay; otherwise notifications cannot be delivered regardless of
464 // the local iOS permission.479 // the local iOS permission.
465 value={notificationStatus === "enabled" && deviceRegistered}480 value={
481 agentAwarenessPushAvailable && notificationStatus === "enabled" && deviceRegistered
482 }
466 onValueChange={handleDeviceNotificationsChange}483 onValueChange={handleDeviceNotificationsChange}
467 />484 />
468 <SettingsSwitchRow485 <SettingsSwitchRow
469 disabled={486 disabled={
470 !isLoaded || liveActivityStatus === "checking" || liveActivityStatus === "linking"487 !agentAwarenessPushAvailable ||
488 !isLoaded ||
489 liveActivityStatus === "checking" ||
490 liveActivityStatus === "linking"
471 }491 }
472 icon="bolt.circle"492 icon="bolt.circle"
473 label="Live Activity Updates"493 label="Live Activity Updates"
474 // Same gate: a saved preference is meaningless until the device494 // Same gate: a saved preference is meaningless until the device
475 // registration the relay needs to push updates has succeeded.495 // registration the relay needs to push updates has succeeded.
476 value={496 value={
497 agentAwarenessPushAvailable &&
477 (liveActivityStatus === "enabled" || liveActivityStatus === "linking") &&498 (liveActivityStatus === "enabled" || liveActivityStatus === "linking") &&
478 deviceRegistered499 deviceRegistered
479 }500 }
apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "../../../../components/AppSymbol";
3import { useCallback, useEffect, useMemo, useRef } from "react";3import { useCallback, useEffect, useMemo, useRef } from "react";
4import { View, type AccessibilityActionEvent } from "react-native";4import { View, type AccessibilityActionEvent } from "react-native";
5import { Gesture, GestureDetector } from "react-native-gesture-handler";5import { Gesture, GestureDetector } from "react-native-gesture-handler";
apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx+1-1
...@@ -26,7 +26,7 @@ export function CodeAppearanceSection() {...@@ -26,7 +26,7 @@ export function CodeAppearanceSection() {
26 );26 );
2727
28 return (28 return (
29 <SettingsSection title="Code & Diffs">29 <SettingsSection card title="Code & Diffs">
30 <CodeAppearancePreview30 <CodeAppearancePreview
31 fontSize={appearance.codeFontSize}31 fontSize={appearance.codeFontSize}
32 wordBreak={appearance.codeWordBreak}32 wordBreak={appearance.codeWordBreak}
apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx+1-1
...@@ -26,7 +26,7 @@ export function TerminalAppearanceSection() {...@@ -26,7 +26,7 @@ export function TerminalAppearanceSection() {
26 );26 );
2727
28 return (28 return (
29 <SettingsSection title="Terminal">29 <SettingsSection card title="Terminal">
30 <TerminalAppearancePreview fontSize={appearance.terminalFontSize} />30 <TerminalAppearancePreview fontSize={appearance.terminalFontSize} />
31 <AppearancePreviewSeparator />31 <AppearancePreviewSeparator />
32 <SettingsSwitchRow32 <SettingsSwitchRow
apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx+1-1
...@@ -15,7 +15,7 @@ export function TextAppearanceSection() {...@@ -15,7 +15,7 @@ export function TextAppearanceSection() {
15 const { isReady, appearance, setBaseFontSize } = useAppearancePreferences();15 const { isReady, appearance, setBaseFontSize } = useAppearancePreferences();
1616
17 return (17 return (
18 <SettingsSection title="Text">18 <SettingsSection card title="Text">
19 <TextAppearancePreview fontSize={appearance.baseFontSize} />19 <TextAppearancePreview fontSize={appearance.baseFontSize} />
20 <AppearancePreviewSeparator />20 <AppearancePreviewSeparator />
21 <FontSizeSliderRow21 <FontSizeSliderRow
apps/mobile/src/features/settings/components/SettingsRow.tsx+2-1
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { useNavigation } from "@react-navigation/native";1import { useNavigation } from "@react-navigation/native";
2import { SymbolView } from "expo-symbols";
3import type { ComponentProps } from "react";2import type { ComponentProps } from "react";
4import { Pressable, View } from "react-native";3import { Pressable, View } from "react-native";
54
5import { SymbolView } from "../../../components/AppSymbol";
6
6import { AppText as Text } from "../../../components/AppText";7import { AppText as Text } from "../../../components/AppText";
7import { useThemeColor } from "../../../lib/useThemeColor";8import { useThemeColor } from "../../../lib/useThemeColor";
8import type { SettingsSheetTarget } from "./settings-sheet-targets";9import type { SettingsSheetTarget } from "./settings-sheet-targets";
apps/mobile/src/features/settings/components/SettingsSection.tsx+13-2
...@@ -3,11 +3,22 @@ import { View } from "react-native";...@@ -3,11 +3,22 @@ import { View } from "react-native";
33
4import { AppText as Text } from "../../../components/AppText";4import { AppText as Text } from "../../../components/AppText";
55
6export function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) {6export function SettingsSection(props: {
7 readonly title: string;
8 readonly children: ReactNode;
9 /** Force the grouped card background; Android otherwise lists options flat. */
10 readonly card?: boolean;
11}) {
7 return (12 return (
8 <View className="gap-2">13 <View className="gap-2">
9 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>14 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>
10 <View className="overflow-hidden rounded-[28px] border-continuous bg-card">15 <View
16 className={
17 props.card
18 ? "overflow-hidden rounded-[24px] border-continuous bg-card"
19 : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent"
20 }
21 >
11 {props.children}22 {props.children}
12 </View>23 </View>
13 </View>24 </View>
apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { SymbolView } from "expo-symbols";
2import type { ComponentProps } from "react";1import type { ComponentProps } from "react";
3import { Switch, View } from "react-native";2import { Switch, View } from "react-native";
43
4import { SymbolView } from "../../../components/AppSymbol";
5import { AppText as Text } from "../../../components/AppText";5import { AppText as Text } from "../../../components/AppText";
6import { useThemeColor } from "../../../lib/useThemeColor";6import { useThemeColor } from "../../../lib/useThemeColor";
77
apps/mobile/src/features/shortcuts/appShortcuts.test.ts created+155
...@@ -0,0 +1,155 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { NavigationState } from "@react-navigation/native";
3
4import type { RecentThreadShortcut } from "../../persistence/imperative";
5import {
6 activeThreadRef,
7 buildShortcutActions,
8 MAX_RECENT_THREAD_SHORTCUTS,
9 NEW_TASK_SHORTCUT_ID,
10 shortcutHref,
11 withRecentThreadShortcut,
12} from "./appShortcuts";
13
14function navState(route: { name: string; params?: unknown }): NavigationState {
15 return { index: 0, routes: [route] } as unknown as NavigationState;
16}
17
18function thread(suffix: string, title = `Thread ${suffix}`): RecentThreadShortcut {
19 return { environmentId: `env-${suffix}`, threadId: `thread-${suffix}`, title };
20}
21
22describe("withRecentThreadShortcut", () => {
23 it("prepends a newly opened thread", () => {
24 const next = withRecentThreadShortcut([thread("a")], thread("b"));
25 expect(next.map((entry) => entry.threadId)).toEqual(["thread-b", "thread-a"]);
26 });
27
28 it("moves a reopened thread to the front without duplicating it", () => {
29 const next = withRecentThreadShortcut([thread("a"), thread("b")], thread("b"));
30 expect(next.map((entry) => entry.threadId)).toEqual(["thread-b", "thread-a"]);
31 });
32
33 it("caps the list at the shortcut budget", () => {
34 const current = [thread("a"), thread("b"), thread("c")];
35 const next = withRecentThreadShortcut(current, thread("d"));
36 expect(next).toHaveLength(MAX_RECENT_THREAD_SHORTCUTS);
37 expect(next[0]?.threadId).toBe("thread-d");
38 expect(next.map((entry) => entry.threadId)).not.toContain("thread-c");
39 });
40
41 it("returns the same array when the thread already leads with the same title", () => {
42 const current = [thread("a"), thread("b")];
43 expect(withRecentThreadShortcut(current, thread("a"))).toBe(current);
44 });
45
46 it("keeps the known title when a reopen records an empty one", () => {
47 const current = [thread("a", "Fix the build")];
48 const next = withRecentThreadShortcut(current, thread("a", ""));
49 expect(next).toBe(current);
50 });
51
52 it("updates the title once the shell provides one", () => {
53 const current = [thread("a", "")];
54 const next = withRecentThreadShortcut(current, thread("a", "Fix the build"));
55 expect(next[0]?.title).toBe("Fix the build");
56 expect(next).toHaveLength(1);
57 });
58});
59
60describe("buildShortcutActions", () => {
61 it("leads with the static new-task action", () => {
62 const actions = buildShortcutActions([thread("a")]);
63 expect(actions[0]?.id).toBe(NEW_TASK_SHORTCUT_ID);
64 expect(actions[0]?.params?.href).toBe("/new");
65 expect(actions).toHaveLength(2);
66 });
67
68 it("deep-links threads with encoded route params", () => {
69 const actions = buildShortcutActions([
70 { environmentId: "env 1", threadId: "thread/2", title: "Spaced out" },
71 ]);
72 expect(actions[1]?.params?.href).toBe("/threads/env%201/thread%2F2");
73 expect(actions[1]?.title).toBe("Spaced out");
74 });
75
76 it("falls back to a generic label for missing titles", () => {
77 const actions = buildShortcutActions([thread("a", " ")]);
78 expect(actions[1]?.title).toBe("Thread");
79 });
80});
81
82describe("shortcutHref", () => {
83 it("accepts exactly the destinations shortcuts can produce", () => {
84 expect(shortcutHref({ id: "x", title: "x", params: { href: "/new" } })).toBe("/new");
85 expect(shortcutHref({ id: "x", title: "x", params: { href: "/threads/env-1/thread-2" } })).toBe(
86 "/threads/env-1/thread-2",
87 );
88 expect(
89 shortcutHref({ id: "x", title: "x", params: { href: "/threads/env%201/thread%2F2" } }),
90 ).toBe("/threads/env%201/thread%2F2");
91 });
92
93 it("rejects everything else", () => {
94 for (const href of [
95 "https://evil.example",
96 "//evil.example",
97 "/settings",
98 "/threads/only-one-segment",
99 "/threads/a/b/c",
100 "/threads//x",
101 "/threads/a/b?x=1",
102 "/threads/a/b#frag",
103 "/new/extra",
104 ]) {
105 expect(shortcutHref({ id: "x", title: "x", params: { href } })).toBe(null);
106 }
107 expect(shortcutHref({ id: "x", title: "x", params: { href: 3 } })).toBe(null);
108 expect(shortcutHref({ id: "x", title: "x" })).toBe(null);
109 });
110});
111
112describe("activeThreadRef", () => {
113 it("resolves the active Thread route's params", () => {
114 const ref = activeThreadRef(
115 navState({ name: "Thread", params: { environmentId: "env-1", threadId: "thread-2" } }),
116 );
117 expect(ref).toEqual({ environmentId: "env-1", threadId: "thread-2" });
118 });
119
120 it("takes the first value of array params", () => {
121 const ref = activeThreadRef(
122 navState({ name: "Thread", params: { environmentId: ["env-1"], threadId: ["thread-2"] } }),
123 );
124 expect(ref).toEqual({ environmentId: "env-1", threadId: "thread-2" });
125 });
126
127 it("returns null for non-thread routes", () => {
128 expect(activeThreadRef(navState({ name: "Home" }))).toBe(null);
129 });
130
131 it("returns null instead of throwing for malformed params", () => {
132 const malformed: unknown[] = [
133 undefined,
134 {},
135 { environmentId: "env-1" },
136 { environmentId: "", threadId: "thread-2" },
137 { environmentId: " ", threadId: "thread-2" },
138 { environmentId: "env-1", threadId: " " },
139 { environmentId: 42, threadId: "thread-2" },
140 { environmentId: { nested: true }, threadId: "thread-2" },
141 { environmentId: [], threadId: [] },
142 ];
143 for (const params of malformed) {
144 expect(activeThreadRef(navState({ name: "Thread", params }))).toBe(null);
145 }
146 });
147});
148
149describe("launcher shortcut ids", () => {
150 it("cannot collide across different env/thread pairs", () => {
151 const a = buildShortcutActions([{ environmentId: "a-b", threadId: "c", title: "x" }]);
152 const b = buildShortcutActions([{ environmentId: "a", threadId: "b-c", title: "x" }]);
153 expect(a[1]?.id).not.toBe(b[1]?.id);
154 });
155});
apps/mobile/src/features/shortcuts/appShortcuts.ts created+137
...@@ -0,0 +1,137 @@
1import type { Action } from "expo-quick-actions";
2import type { NavigationState } from "@react-navigation/native";
3import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
4
5import type { RecentThreadShortcut } from "../../persistence/imperative";
6
7// Launchers cap visible shortcuts around 4; one slot is the static
8// "New task" entry, the rest rotate through recently opened threads.
9export const MAX_RECENT_THREAD_SHORTCUTS = 3;
10
11export const NEW_TASK_SHORTCUT_ID = "new-task";
12const NEW_TASK_SHORTCUT_HREF = "/new";
13
14// Adaptive icon resource generated by the expo-quick-actions config plugin
15// (androidIcons key in app.config.ts).
16const SHORTCUT_ICON = "shortcut_icon";
17
18// Matches only the thread deep-link shape shortcuts are allowed to carry:
19// exactly two non-empty segments, no nested paths, queries, or fragments.
20const THREAD_SHORTCUT_HREF_PATTERN = /^\/threads\/[^/?#]+\/[^/?#]+$/;
21
22function threadShortcutHref(thread: RecentThreadShortcut): string {
23 return `/threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`;
24}
25
26function firstRouteParam(value: string | string[] | undefined): string | null {
27 if (Array.isArray(value)) {
28 return value[0] ?? null;
29 }
30
31 return value ?? null;
32}
33
34/**
35 * Thread ref of the navigation state's ACTIVE route, or null. Never throws:
36 * route params can arrive malformed from external deep links or restored
37 * state, the branded id constructors throw on values failing their schema,
38 * and this runs during render of the root stack layout — an uncaught throw
39 * here would take down the whole navigation tree.
40 */
41export function activeThreadRef(state: NavigationState): ScopedThreadRef | null {
42 const route = state.routes[state.index];
43 if (route?.name !== "Thread") {
44 return null;
45 }
46
47 try {
48 const params = route.params as
49 | {
50 readonly environmentId?: string | string[];
51 readonly threadId?: string | string[];
52 }
53 | undefined;
54 const environmentId = firstRouteParam(params?.environmentId)?.trim();
55 const threadId = firstRouteParam(params?.threadId)?.trim();
56 if (!environmentId || !threadId) {
57 return null;
58 }
59
60 return {
61 environmentId: EnvironmentId.make(environmentId),
62 threadId: ThreadId.make(threadId),
63 };
64 } catch {
65 return null;
66 }
67}
68
69function threadShortcutLabel(thread: RecentThreadShortcut): string {
70 const title = thread.title.trim();
71 return title.length > 0 ? title : "Thread";
72}
73
74/**
75 * In-app navigation path carried by a shortcut, or null for anything
76 * malformed — shortcuts persist in the launcher across app updates, so this
77 * allowlists the exact destinations shortcuts can produce rather than
78 * trusting any path-shaped string (`//host`, arbitrary routes, stale
79 * persisted junk all navigate nowhere).
80 */
81export function shortcutHref(action: Action): string | null {
82 const href = action.params?.href;
83 if (typeof href !== "string") {
84 return null;
85 }
86
87 return href === NEW_TASK_SHORTCUT_HREF || THREAD_SHORTCUT_HREF_PATTERN.test(href) ? href : null;
88}
89
90/**
91 * Records an opened thread as the most recent shortcut entry. Returns the
92 * SAME array when nothing changed so effect chains keyed on identity skip
93 * redundant launcher/storage writes. An empty title never clobbers a known
94 * one — shells load after navigation, so the first record of a thread often
95 * predates its title.
96 */
97export function withRecentThreadShortcut(
98 current: ReadonlyArray<RecentThreadShortcut>,
99 opened: RecentThreadShortcut,
100): ReadonlyArray<RecentThreadShortcut> {
101 const existing = current.find(
102 (thread) =>
103 thread.environmentId === opened.environmentId && thread.threadId === opened.threadId,
104 );
105 const title = opened.title.trim().length > 0 ? opened.title : (existing?.title ?? opened.title);
106 if (current[0] === existing && existing !== undefined && existing.title === title) {
107 return current;
108 }
109
110 return [
111 { environmentId: opened.environmentId, threadId: opened.threadId, title },
112 ...current.filter((thread) => thread !== existing),
113 ].slice(0, MAX_RECENT_THREAD_SHORTCUTS);
114}
115
116/** Full launcher shortcut list: static "New task" first, then recents. */
117export function buildShortcutActions(recents: ReadonlyArray<RecentThreadShortcut>): Action[] {
118 return [
119 {
120 id: NEW_TASK_SHORTCUT_ID,
121 title: "New task",
122 icon: SHORTCUT_ICON,
123 params: { href: NEW_TASK_SHORTCUT_HREF },
124 },
125 ...recents.slice(0, MAX_RECENT_THREAD_SHORTCUTS).map(
126 (thread): Action => ({
127 // The encoded href doubles as the launcher id: URI-encoding makes the
128 // env/thread join unambiguous (a plain `-` join lets different pairs
129 // collide and overwrite each other's launcher slots).
130 id: `thread:${threadShortcutHref(thread)}`,
131 title: threadShortcutLabel(thread),
132 icon: SHORTCUT_ICON,
133 params: { href: threadShortcutHref(thread) },
134 }),
135 ),
136 ];
137}
apps/mobile/src/features/shortcuts/useAppShortcuts.ts created+136
...@@ -0,0 +1,136 @@
1import * as QuickActions from "expo-quick-actions";
2import { useEffect, useMemo, useRef, useState } from "react";
3import { Platform } from "react-native";
4import { useLinkTo, type NavigationState } from "@react-navigation/native";
5
6import {
7 loadRecentThreadShortcuts,
8 saveRecentThreadShortcuts,
9 type RecentThreadShortcut,
10} from "../../persistence/imperative";
11import { useThreadShell } from "../../state/entities";
12import {
13 activeThreadRef,
14 buildShortcutActions,
15 shortcutHref,
16 withRecentThreadShortcut,
17} from "./appShortcuts";
18
19/**
20 * Owns the launcher app shortcuts (Android long-press menu): keeps the
21 * static "New task" entry plus the recently opened threads in sync, and
22 * routes shortcut taps — cold start included — to their in-app screens.
23 * Mounted once in the root stack layout.
24 */
25export function useAppShortcuts(state: NavigationState): void {
26 useShortcutNavigation();
27 useRecentThreadShortcutSync(state);
28}
29
30function useShortcutNavigation(): void {
31 const linkTo = useLinkTo();
32 const handledInitialAction = useRef(false);
33
34 useEffect(() => {
35 // Cold start: the tapped shortcut arrives as the launch action, before
36 // any listener can fire. Navigating from here pushes the target over the
37 // initial Home route, so back returns home instead of exiting the app.
38 if (!handledInitialAction.current) {
39 handledInitialAction.current = true;
40 const initialHref = QuickActions.initial ? shortcutHref(QuickActions.initial) : null;
41 if (initialHref !== null) {
42 linkTo(initialHref);
43 }
44 }
45
46 const subscription = QuickActions.addListener((action) => {
47 const href = shortcutHref(action);
48 if (href !== null) {
49 linkTo(href);
50 }
51 });
52 return () => subscription.remove();
53 }, [linkTo]);
54}
55
56function useRecentThreadShortcutSync(state: NavigationState): void {
57 const threadRef = useMemo(() => activeThreadRef(state), [state]);
58 const threadShell = useThreadShell(threadRef);
59 // null until the persisted list loads; recording waits on it so the first
60 // thread opened after a cold start cannot clobber older entries.
61 const [recents, setRecents] = useState<ReadonlyArray<RecentThreadShortcut> | null>(null);
62 // Gates storage writes: a failed load falls back to an empty in-memory
63 // list (so the launcher still gets the "New task" item), but persisting
64 // that fallback would erase valid history over a transient read error.
65 // Real thread opens flip this on — by then the list is the new truth.
66 const persistableRef = useRef(false);
67 // Saves are fire-and-forget; chaining them keeps an older list from
68 // finishing after (and overwriting) a newer one.
69 const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
70
71 useEffect(() => {
72 if (Platform.OS !== "android") {
73 return;
74 }
75
76 let cancelled = false;
77 void loadRecentThreadShortcuts()
78 .then((threads) => {
79 if (!cancelled) {
80 persistableRef.current = true;
81 setRecents(threads);
82 }
83 })
84 .catch((error) => {
85 console.warn("[app-shortcuts] failed to load recent threads", error);
86 if (!cancelled) {
87 setRecents([]);
88 }
89 });
90 return () => {
91 cancelled = true;
92 };
93 }, []);
94
95 const loaded = recents !== null;
96 const environmentId = threadRef?.environmentId ?? null;
97 const threadId = threadRef?.threadId ?? null;
98 const title = threadShell?.title ?? "";
99 useEffect(() => {
100 if (!loaded || environmentId === null || threadId === null) {
101 return;
102 }
103
104 // withRecentThreadShortcut returns the same array when nothing changed,
105 // so React bails out and the persist effect below does not re-fire.
106 setRecents((current) => {
107 if (current === null) {
108 return current;
109 }
110 const next = withRecentThreadShortcut(current, { environmentId, threadId, title });
111 if (next !== current) {
112 persistableRef.current = true;
113 }
114 return next;
115 });
116 }, [loaded, environmentId, threadId, title]);
117
118 useEffect(() => {
119 if (recents === null) {
120 return;
121 }
122
123 if (persistableRef.current) {
124 saveQueueRef.current = saveQueueRef.current.then(
125 () =>
126 saveRecentThreadShortcuts(recents).catch((error) => {
127 console.warn("[app-shortcuts] failed to persist recent threads", error);
128 }),
129 () => undefined,
130 );
131 }
132 void QuickActions.setItems(buildShortcutActions(recents)).catch((error) => {
133 console.warn("[app-shortcuts] failed to update launcher shortcuts", error);
134 });
135 }, [recents]);
136}
apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx+91-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts";1import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "../../components/AppSymbol";
3import { memo, useCallback, useEffect, useMemo, useRef } from "react";3import { memo, useCallback, useEffect, useMemo, useRef } from "react";
4import { Pressable, View } from "react-native";4import { Pressable, View } from "react-native";
55
...@@ -32,6 +32,8 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(...@@ -32,6 +32,8 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
32) {32) {
33 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");33 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
34 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");34 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
35 const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");
36 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
35 const nativeTerminalAvailable = hasNativeTerminalSurface();37 const nativeTerminalAvailable = hasNativeTerminalSurface();
36 const terminalId = DEFAULT_TERMINAL_ID;38 const terminalId = DEFAULT_TERMINAL_ID;
37 const lastGridSizeRef = useRef<TerminalGridSize>({39 const lastGridSizeRef = useRef<TerminalGridSize>({
...@@ -63,6 +65,94 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(...@@ -63,6 +65,94 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
63 const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`;65 const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`;
64 const isRunning = terminal.status === "running" || terminal.status === "starting";66 const isRunning = terminal.status === "running" || terminal.status === "starting";
6567
68 // Close the session and dismiss the panel when the process ends while
69 // attached (e.g. typing `exit`), mirroring the web drawer's
70 // onSessionExited flow.
71 const runningTerminalKeyRef = useRef<string | null>(null);
72 const reopenedStaleTerminalKeyRef = useRef<string | null>(null);
73
74 // Attach subscriptions are cached with an idle TTL; reopening the panel
75 // after its session ended reuses the stale stream without a new attach
76 // RPC. Issue an explicit open so the server respawns the session and its
77 // snapshot flows into the live subscription.
78 useEffect(() => {
79 if (isRunning) {
80 reopenedStaleTerminalKeyRef.current = null;
81 return;
82 }
83 if (
84 attachInput === null ||
85 (terminal.status !== "closed" && terminal.status !== "exited") ||
86 terminal.version === 0 ||
87 runningTerminalKeyRef.current === terminalKey ||
88 reopenedStaleTerminalKeyRef.current === terminalKey
89 ) {
90 return;
91 }
92 reopenedStaleTerminalKeyRef.current = terminalKey;
93 void openTerminal({
94 environmentId: props.environmentId,
95 input: {
96 threadId: props.threadId,
97 terminalId,
98 cwd: props.cwd,
99 worktreePath: props.worktreePath,
100 cols: lastGridSizeRef.current.cols,
101 rows: lastGridSizeRef.current.rows,
102 },
103 }).then((result) => {
104 // Release the guard on failure so a later render can retry the respawn.
105 if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) {
106 reopenedStaleTerminalKeyRef.current = null;
107 }
108 });
109 }, [
110 attachInput,
111 isRunning,
112 openTerminal,
113 props.cwd,
114 props.environmentId,
115 props.threadId,
116 props.worktreePath,
117 terminal.status,
118 terminal.version,
119 terminalId,
120 terminalKey,
121 ]);
122
123 useEffect(() => {
124 // Forget both markers while hidden: if the process ends while the panel
125 // is unobserved (or was just auto-closed), the next show must take the
126 // stale-reopen path instead of treating it as a live exit or skipping
127 // the respawn.
128 if (attachInput === null) {
129 runningTerminalKeyRef.current = null;
130 reopenedStaleTerminalKeyRef.current = null;
131 return;
132 }
133 if (isRunning) {
134 runningTerminalKeyRef.current = terminalKey;
135 return;
136 }
137 // The web drawer treats both exited and closed as session end.
138 const sessionEnded = terminal.status === "exited" || terminal.status === "closed";
139 if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) {
140 return;
141 }
142 runningTerminalKeyRef.current = null;
143 // Mark this key handled so the stale-attach effect doesn't respawn the
144 // session the user just ended.
145 reopenedStaleTerminalKeyRef.current = terminalKey;
146 void closeTerminal({
147 environmentId: props.environmentId,
148 input: {
149 threadId: props.threadId,
150 terminalId,
151 },
152 });
153 props.onClose();
154 }, [attachInput, closeTerminal, isRunning, props, terminal.status, terminalId, terminalKey]);
155
66 const sendResize = useCallback(156 const sendResize = useCallback(
67 (size: TerminalGridSize) => {157 (size: TerminalGridSize) => {
68 void resizeTerminal({158 void resizeTerminal({
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+268-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
3import { SymbolView } from "expo-symbols";3import type { MenuAction } from "@react-native-menu/menu";
4import { SymbolView } from "../../components/AppSymbol";
4import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";5import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";6import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
6import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
...@@ -12,11 +13,13 @@ import {...@@ -12,11 +13,13 @@ import {
12 useKeyboardState,13 useKeyboardState,
13} from "react-native-keyboard-controller";14} from "react-native-keyboard-controller";
1415
16import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
15import {17import {
16 ComposerToolbarButton,18 ComposerToolbarButton,
17 ComposerToolbarRow,19 ComposerToolbarRow,
18 ComposerToolbarScroller,20 ComposerToolbarScroller,
19} from "../../components/ComposerToolbarTrigger";21} from "../../components/ComposerToolbarTrigger";
22import { ControlPillMenu } from "../../components/ControlPill";
20import { EmptyState } from "../../components/EmptyState";23import { EmptyState } from "../../components/EmptyState";
21import { GlassSurface } from "../../components/GlassSurface";24import { GlassSurface } from "../../components/GlassSurface";
22import { LoadingScreen } from "../../components/LoadingScreen";25import { LoadingScreen } from "../../components/LoadingScreen";
...@@ -58,6 +61,7 @@ import {...@@ -58,6 +61,7 @@ import {
58 buildTerminalMenuSessions,61 buildTerminalMenuSessions,
59 getTerminalStatusLabel,62 getTerminalStatusLabel,
60 nextOpenTerminalId,63 nextOpenTerminalId,
64 previousLiveTerminalId,
61 resolveTerminalSessionLabel,65 resolveTerminalSessionLabel,
62 type TerminalMenuSession,66 type TerminalMenuSession,
63} from "./terminalMenu";67} from "./terminalMenu";
...@@ -158,6 +162,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -158,6 +162,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
158 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");162 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
159 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");163 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
160 const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear");164 const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear");
165 const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");
166 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
161 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");167 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
162 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";168 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
163 const { state: workspaceState } = useWorkspaceState();169 const { state: workspaceState } = useWorkspaceState();
...@@ -346,6 +352,64 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -346,6 +352,64 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
346 });352 });
347 const isRunning = terminal.status === "running" || terminal.status === "starting";353 const isRunning = terminal.status === "running" || terminal.status === "starting";
348354
355 // When the process ends while this screen is attached (e.g. typing `exit`),
356 // close the session and leave the screen, mirroring the web drawer's
357 // onSessionExited flow. Only react to a running -> exited transition
358 // observed on this screen so already-exited sessions can still be opened
359 // (they restart on attach).
360 const runningTerminalKeyRef = useRef<string | null>(null);
361 const reopenedStaleTerminalKeyRef = useRef<string | null>(null);
362 const pendingExitNavigationRef = useRef<string | null>(null);
363
364 // Attach subscriptions are cached with an idle TTL, so revisiting a
365 // terminal whose session ended while unobserved reuses the stale stream
366 // without a new attach RPC — the server never respawns anything. Detect
367 // that (dead status with processed events, never seen running here) and
368 // issue an explicit open; its snapshot flows into the live subscription.
369 useEffect(() => {
370 if (isRunning) {
371 reopenedStaleTerminalKeyRef.current = null;
372 return;
373 }
374 if (
375 terminalAttachInput === null ||
376 !selectedThread ||
377 (terminal.status !== "closed" && terminal.status !== "exited") ||
378 terminal.version === 0 ||
379 runningTerminalKeyRef.current === terminalKey ||
380 reopenedStaleTerminalKeyRef.current === terminalKey
381 ) {
382 return;
383 }
384 reopenedStaleTerminalKeyRef.current = terminalKey;
385 void openTerminal({
386 environmentId: selectedThread.environmentId,
387 input: {
388 threadId: selectedThread.id,
389 terminalId,
390 cwd: terminalAttachInput.cwd,
391 worktreePath: terminalAttachInput.worktreePath,
392 cols: terminalAttachInput.cols,
393 rows: terminalAttachInput.rows,
394 ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}),
395 },
396 }).then((result) => {
397 // Release the guard on failure so a later render can retry the respawn.
398 if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) {
399 reopenedStaleTerminalKeyRef.current = null;
400 }
401 });
402 }, [
403 isRunning,
404 openTerminal,
405 selectedThread,
406 terminal.status,
407 terminal.version,
408 terminalAttachInput,
409 terminalId,
410 terminalKey,
411 ]);
412
349 useEffect(() => {413 useEffect(() => {
350 terminalDebugLog("surface:props", {414 terminalDebugLog("surface:props", {
351 terminalKey,415 terminalKey,
...@@ -737,6 +801,105 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -737,6 +801,105 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
737 [navigation, selectedThread, terminalId],801 [navigation, selectedThread, terminalId],
738 );802 );
739803
804 const navigateAwayAfterExit = useCallback(() => {
805 // With other shells still live, fall through to the previous one instead
806 // of dropping the user back on the thread.
807 const fallbackTerminalId = previousLiveTerminalId({
808 sessions: terminalMenuSessions,
809 exitedTerminalId: terminalId,
810 });
811 if (fallbackTerminalId !== null && selectedThread) {
812 navigation.dispatch(
813 StackActions.replace("ThreadTerminal", {
814 environmentId: String(selectedThread.environmentId),
815 threadId: String(selectedThread.id),
816 terminalId: fallbackTerminalId,
817 }),
818 );
819 return;
820 }
821 if (navigation.canGoBack()) {
822 navigation.goBack();
823 return;
824 }
825 // Deep-linked/root mounts have nothing to pop; land on the thread
826 // instead of stranding the user on a dead terminal.
827 if (selectedThread) {
828 navigation.dispatch(
829 StackActions.replace("Thread", {
830 environmentId: String(selectedThread.environmentId),
831 threadId: String(selectedThread.id),
832 }),
833 );
834 }
835 }, [navigation, selectedThread, terminalId, terminalMenuSessions]);
836
837 useEffect(() => {
838 // Detached (hidden surface or environment drop): forget the running
839 // marker so a reattach takes the stale-reopen path instead of misreading
840 // the dead snapshot as an exit observed on this screen. A pending exit
841 // navigation stays armed — it only clears once the session runs again —
842 // so refocusing a dead screen still leaves it.
843 if (terminalAttachInput === null) {
844 runningTerminalKeyRef.current = null;
845 return;
846 }
847 if (isRunning) {
848 runningTerminalKeyRef.current = terminalKey;
849 // The session came back (e.g. respawned elsewhere) before the user
850 // returned; a stale pending exit must not eject a live terminal.
851 pendingExitNavigationRef.current = null;
852 return;
853 }
854 // The web drawer treats both exited and closed as session end.
855 const sessionEnded = terminal.status === "exited" || terminal.status === "closed";
856 if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) {
857 return;
858 }
859 runningTerminalKeyRef.current = null;
860 // Mark this key handled so the stale-attach effect doesn't respawn the
861 // session the user just ended.
862 reopenedStaleTerminalKeyRef.current = terminalKey;
863 if (selectedThread) {
864 void closeTerminal({
865 environmentId: selectedThread.environmentId,
866 input: {
867 threadId: selectedThread.id,
868 terminalId,
869 },
870 });
871 }
872 if (navigation.isFocused()) {
873 navigateAwayAfterExit();
874 return;
875 }
876 // An unfocused screen can't navigate; leave when the user returns so
877 // they never land on the dead session.
878 pendingExitNavigationRef.current = terminalKey;
879 }, [
880 closeTerminal,
881 isRunning,
882 navigateAwayAfterExit,
883 navigation,
884 selectedThread,
885 terminal.status,
886 terminalAttachInput,
887 terminalId,
888 terminalKey,
889 ]);
890
891 useEffect(
892 () =>
893 navigation.addListener("focus", () => {
894 if (pendingExitNavigationRef.current !== terminalKey) {
895 return;
896 }
897 pendingExitNavigationRef.current = null;
898 navigateAwayAfterExit();
899 }),
900 [navigateAwayAfterExit, navigation, terminalKey],
901 );
902
740 const handleOpenNewTerminal = useCallback(() => {903 const handleOpenNewTerminal = useCallback(() => {
741 if (!selectedThread) {904 if (!selectedThread) {
742 return;905 return;
...@@ -762,6 +925,69 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -762,6 +925,69 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
762 setTerminalFontSize(stepTerminalFontSize(fontSize, 1));925 setTerminalFontSize(stepTerminalFontSize(fontSize, 1));
763 }, [fontSize, setTerminalFontSize]);926 }, [fontSize, setTerminalFontSize]);
764927
928 // Android mirror of the iOS NativeHeaderToolbar terminal menu below: text
929 // size, session switching, and "Open new terminal", rendered through the
930 // token-styled anchored menu (the native header items are iOS-only).
931 const androidTerminalMenuActions = useMemo<MenuAction[]>(
932 () => [
933 {
934 id: "text-size",
935 title: "Text size",
936 subactions: [
937 {
938 id: "font-decrease",
939 title: `A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`,
940 attributes: fontSize <= MIN_TERMINAL_FONT_SIZE ? { disabled: true } : undefined,
941 },
942 {
943 id: "font-increase",
944 title: `A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`,
945 attributes: fontSize >= MAX_TERMINAL_FONT_SIZE ? { disabled: true } : undefined,
946 },
947 ],
948 },
949 ...terminalMenuSessions.map(
950 (session): MenuAction => ({
951 id: `terminal-session:${session.terminalId}`,
952 title: session.displayLabel,
953 subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)]
954 .filter(Boolean)
955 .join(" · "),
956 state: session.terminalId === terminalId ? ("on" as const) : undefined,
957 }),
958 ),
959 {
960 id: "terminal-new",
961 title: "Open new terminal",
962 image: "plus",
963 subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`,
964 },
965 ],
966 [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions],
967 );
968
969 const handleAndroidTerminalMenuAction = useCallback(
970 (event: { nativeEvent: { event: string } }) => {
971 const id = event.nativeEvent.event;
972 if (id === "font-decrease") {
973 handleDecreaseFontSize();
974 return;
975 }
976 if (id === "font-increase") {
977 handleIncreaseFontSize();
978 return;
979 }
980 if (id === "terminal-new") {
981 handleOpenNewTerminal();
982 return;
983 }
984 if (id.startsWith("terminal-session:")) {
985 handleSelectTerminal(id.slice("terminal-session:".length));
986 }
987 },
988 [handleDecreaseFontSize, handleIncreaseFontSize, handleOpenNewTerminal, handleSelectTerminal],
989 );
990
765 const handleClearTerminal = useCallback(() => {991 const handleClearTerminal = useCallback(() => {
766 if (!selectedThread) {992 if (!selectedThread) {
767 return;993 return;
...@@ -858,12 +1084,53 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)...@@ -858,12 +1084,53 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
858 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty1084 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty
859 // scrolls internally, nothing for glass to sample). Default title/subtitle1085 // scrolls internally, nothing for glass to sample). Default title/subtitle
860 // styling, like every other page.1086 // styling, like every other page.
1087 // Android draws its own in-flow header (AndroidScreenHeader below);
1088 // the native stack header stays iOS-only.
1089 headerShown: Platform.OS !== "android",
861 title: "Terminal",1090 title: "Terminal",
862 unstable_headerSubtitle:1091 unstable_headerSubtitle:
863 usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined,1092 usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined,
864 }}1093 }}
865 />1094 />
8661095
1096 {Platform.OS === "android" ? (
1097 <AndroidScreenHeader
1098 title="Terminal"
1099 subtitle={headerSubtitle}
1100 onBack={navigation.canGoBack() ? () => navigation.goBack() : undefined}
1101 trailing={
1102 <>
1103 {layout.usesSplitView ? (
1104 <AndroidHeaderIconButton
1105 accessibilityLabel={
1106 panes.primarySidebarVisible ? "Maximize terminal" : "Show threads"
1107 }
1108 icon={
1109 panes.primarySidebarVisible
1110 ? "arrow.up.left.and.arrow.down.right"
1111 : "sidebar.left"
1112 }
1113 onPress={togglePrimarySidebar}
1114 />
1115 ) : null}
1116 {isEnvironmentReady ? (
1117 <ControlPillMenu
1118 actions={androidTerminalMenuActions}
1119 isAnchoredToRight
1120 title={getTerminalStatusLabel({
1121 status: terminal.status,
1122 hasRunningSubprocess: terminal.hasRunningSubprocess,
1123 })}
1124 onPressAction={handleAndroidTerminalMenuAction}
1125 >
1126 <AndroidHeaderIconButton accessibilityLabel="Terminal options" icon="terminal" />
1127 </ControlPillMenu>
1128 ) : null}
1129 </>
1130 }
1131 />
1132 ) : null}
1133
867 {layout.usesSplitView ? (1134 {layout.usesSplitView ? (
868 <NativeHeaderToolbar placement="left">1135 <NativeHeaderToolbar placement="left">
869 <NativeHeaderToolbar.Button1136 <NativeHeaderToolbar.Button
apps/mobile/src/features/terminal/terminalMenu.test.ts+70
...@@ -9,9 +9,25 @@ import {...@@ -9,9 +9,25 @@ import {
9 buildTerminalMenuSessions,9 buildTerminalMenuSessions,
10 nextOpenTerminalId,10 nextOpenTerminalId,
11 nextTerminalId,11 nextTerminalId,
12 previousLiveTerminalId,
12 resolveProjectScriptTerminalId,13 resolveProjectScriptTerminalId,
14 type TerminalMenuSession,
13} from "./terminalMenu";15} from "./terminalMenu";
1416
17function makeMenuSession(input: {
18 readonly terminalId: string;
19 readonly status: TerminalMenuSession["status"];
20}): TerminalMenuSession {
21 return {
22 terminalId: input.terminalId,
23 cwd: null,
24 status: input.status,
25 hasRunningSubprocess: false,
26 displayLabel: getTerminalLabel(input.terminalId),
27 updatedAt: null,
28 };
29}
30
15function makeKnownSession(input: {31function makeKnownSession(input: {
16 readonly terminalId: string;32 readonly terminalId: string;
17 readonly status: KnownTerminalSession["state"]["status"];33 readonly status: KnownTerminalSession["state"]["status"];
...@@ -144,6 +160,60 @@ describe("nextOpenTerminalId", () => {...@@ -144,6 +160,60 @@ describe("nextOpenTerminalId", () => {
144 });160 });
145});161});
146162
163describe("previousLiveTerminalId", () => {
164 it("returns null when no other live session remains", () => {
165 expect(
166 previousLiveTerminalId({
167 sessions: [
168 makeMenuSession({ terminalId: "term-2", status: "exited" }),
169 makeMenuSession({ terminalId: "term-3", status: "closed" }),
170 ],
171 exitedTerminalId: "term-2",
172 }),
173 ).toBe(null);
174 });
175
176 it("prefers the nearest live session below the exited id", () => {
177 expect(
178 previousLiveTerminalId({
179 sessions: [
180 makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "running" }),
181 makeMenuSession({ terminalId: "term-2", status: "running" }),
182 makeMenuSession({ terminalId: "term-3", status: "exited" }),
183 makeMenuSession({ terminalId: "term-4", status: "running" }),
184 ],
185 exitedTerminalId: "term-3",
186 }),
187 ).toBe("term-2");
188 });
189
190 it("falls back to the nearest live session above when the exited id was lowest", () => {
191 expect(
192 previousLiveTerminalId({
193 sessions: [
194 makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "exited" }),
195 makeMenuSession({ terminalId: "term-2", status: "starting" }),
196 makeMenuSession({ terminalId: "term-4", status: "running" }),
197 ],
198 exitedTerminalId: DEFAULT_TERMINAL_ID,
199 }),
200 ).toBe("term-2");
201 });
202
203 it("ignores dead sessions when picking the fallback", () => {
204 expect(
205 previousLiveTerminalId({
206 sessions: [
207 makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "running" }),
208 makeMenuSession({ terminalId: "term-2", status: "exited" }),
209 makeMenuSession({ terminalId: "term-3", status: "exited" }),
210 ],
211 exitedTerminalId: "term-3",
212 }),
213 ).toBe(DEFAULT_TERMINAL_ID);
214 });
215});
216
147describe("resolveProjectScriptTerminalId", () => {217describe("resolveProjectScriptTerminalId", () => {
148 it("reuses the default shell when no terminal is running", () => {218 it("reuses the default shell when no terminal is running", () => {
149 expect(219 expect(
apps/mobile/src/features/terminal/terminalMenu.ts+29
...@@ -114,6 +114,35 @@ export function buildTerminalMenuSessions(input: {...@@ -114,6 +114,35 @@ export function buildTerminalMenuSessions(input: {
114 return Arr.sort(sessionsById.values(), terminalMenuSessionOrder);114 return Arr.sort(sessionsById.values(), terminalMenuSessionOrder);
115}115}
116116
117/**
118 * Picks the session to show after a terminal exits: the nearest live session
119 * below the exited id (terminal n-1), falling back to the nearest one above.
120 * Returns null when no other live session remains and the terminal UI should
121 * be dismissed instead.
122 */
123export function previousLiveTerminalId(input: {
124 readonly sessions: ReadonlyArray<TerminalMenuSession>;
125 readonly exitedTerminalId: string;
126}): string | null {
127 const live = Arr.sort(
128 input.sessions.filter(
129 (session) =>
130 session.terminalId !== input.exitedTerminalId &&
131 (session.status === "running" || session.status === "starting"),
132 ),
133 terminalMenuSessionOrder,
134 );
135 if (live.length === 0) {
136 return null;
137 }
138
139 const below = live.filter(
140 (session) =>
141 session.terminalId.localeCompare(input.exitedTerminalId, undefined, { numeric: true }) < 0,
142 );
143 return (below[below.length - 1] ?? live[0])?.terminalId ?? null;
144}
145
117export function resolveProjectScriptTerminalId(input: {146export function resolveProjectScriptTerminalId(input: {
118 readonly existingTerminalIds: ReadonlyArray<string>;147 readonly existingTerminalIds: ReadonlyArray<string>;
119 readonly hasRunningTerminal: boolean;148 readonly hasRunningTerminal: boolean;
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";2import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
3import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";3import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
4import { SymbolView } from "expo-symbols";4import { SymbolView } from "../../components/AppSymbol";
5import { memo } from "react";5import { memo } from "react";
6import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native";6import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native";
77
apps/mobile/src/features/threads/GitActionProgressOverlay.tsx+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "../../components/AppSymbol";
3import { useCallback, useEffect, useRef } from "react";3import { useCallback, useEffect, useRef } from "react";
4import { ActivityIndicator, Pressable, View } from "react-native";4import { ActivityIndicator, Pressable, View } from "react-native";
5import Animated, { FadeIn, FadeOut } from "react-native-reanimated";5import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+209-87
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import { NativeStackScreenOptions } from "../../native/StackHeader";1import { NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation } from "@react-navigation/native";2import { StackActions, useNavigation } from "@react-navigation/native";
3import { useCallback, useEffect, useMemo, useRef } from "react";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { Alert, InteractionManager, View, useColorScheme } from "react-native";4import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
5import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";5import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
6import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";
8import { useFontFamily } from "../../lib/useFontFamily";
79
8import { EnvironmentId } from "@t3tools/contracts";10import { EnvironmentId } from "@t3tools/contracts";
9import {11import {
...@@ -18,9 +20,11 @@ import {...@@ -18,9 +20,11 @@ import {
18 ComposerToolbarScroller,20 ComposerToolbarScroller,
19 ComposerToolbarTrigger,21 ComposerToolbarTrigger,
20} from "../../components/ComposerToolbarTrigger";22} from "../../components/ComposerToolbarTrigger";
23import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
21import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";24import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
22import { ControlPillMenu } from "../../components/ControlPill";25import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
23import { ProviderIcon } from "../../components/ProviderIcon";26import { ProviderIcon } from "../../components/ProviderIcon";
27import { ComposerSurface } from "./ThreadComposer";
2428
25import { makeTurnCommandMetadata } from "../../lib/commandMetadata";29import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
26import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";30import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
...@@ -77,6 +81,7 @@ export function NewTaskDraftScreen(props: {...@@ -77,6 +81,7 @@ export function NewTaskDraftScreen(props: {
77 )?.connectionState === "connected";81 )?.connectionState === "connected";
78 const promptInputRef = useRef<ComposerEditorHandle>(null);82 const promptInputRef = useRef<ComposerEditorHandle>(null);
79 const loadedBranchesProjectKeyRef = useRef<string | null>(null);83 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
84 const [isComposerFocused, setIsComposerFocused] = useState(false);
80 const appliedInitialProjectKeyRef = useRef<string | null>(null);85 const appliedInitialProjectKeyRef = useRef<string | null>(null);
81 useEffect(() => {86 useEffect(() => {
82 return () => {87 return () => {
...@@ -111,6 +116,9 @@ export function NewTaskDraftScreen(props: {...@@ -111,6 +116,9 @@ export function NewTaskDraftScreen(props: {
111 };116 };
112 }, [props.pendingTaskId, cancelEditingPendingTask]);117 }, [props.pendingTaskId, cancelEditingPendingTask]);
113118
119 const foregroundColor = useThemeColor("--color-foreground");
120 const regularFontFamily = useFontFamily("regular");
121 const bodyText = useScaledTextRole("body");
114 const headlineText = useScaledTextRole("headline");122 const headlineText = useScaledTextRole("headline");
115 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";123 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
116 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";124 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
...@@ -191,7 +199,9 @@ export function NewTaskDraftScreen(props: {...@@ -191,7 +199,9 @@ export function NewTaskDraftScreen(props: {
191 }, [flow.loadBranches, selectedProject]);199 }, [flow.loadBranches, selectedProject]);
192200
193 useEffect(() => {201 useEffect(() => {
194 if (!selectedProject) {202 // Android starts with the collapsed composer pill (like an open thread)
203 // and only expands/focuses when tapped.
204 if (!selectedProject || Platform.OS === "android") {
195 return;205 return;
196 }206 }
197207
...@@ -589,7 +599,198 @@ export function NewTaskDraftScreen(props: {...@@ -589,7 +599,198 @@ export function NewTaskDraftScreen(props: {
589 if (!selectedProject) {599 if (!selectedProject) {
590 return (600 return (
591 <View className="flex-1 bg-sheet">601 <View className="flex-1 bg-sheet">
592 <NativeStackScreenOptions options={{ title: "Loading task" }} />602 {Platform.OS === "android" ? (
603 <>
604 <NativeStackScreenOptions options={{ headerShown: false }} />
605 <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} />
606 </>
607 ) : (
608 <NativeStackScreenOptions options={{ title: "Loading task" }} />
609 )}
610 </View>
611 );
612 }
613
614 const isAndroid = Platform.OS === "android";
615 const isDarkMode = colorScheme === "dark";
616 // Android expansion follows native editor focus so relayout cannot race
617 // the touch gesture that opens the keyboard.
618 const isExpanded = !isAndroid || isComposerFocused;
619 const canStart =
620 Boolean(flow.selectedProject) &&
621 Boolean(flow.selectedModel) &&
622 flow.prompt.trim().length > 0 &&
623 !flow.submitting &&
624 !(flow.workspaceMode === "worktree" && !flow.selectedBranchName);
625 const promptEditor = (
626 <ComposerEditor
627 ref={promptInputRef}
628 autoFocus={!isAndroid}
629 multiline
630 scrollEnabled={isExpanded}
631 value={flow.prompt}
632 skills={flow.selectedProviderSkills}
633 onChangeText={flow.setPrompt}
634 onFocus={() => setIsComposerFocused(true)}
635 onBlur={() => setIsComposerFocused(false)}
636 onPasteImages={(uris) => void handleNativePasteImages(uris)}
637 placeholder={`Describe a coding task in ${selectedProject.title}`}
638 // Same collapsed centering as ThreadComposer: native vertical gravity
639 // in a pill-height box.
640 singleLineCentered={!isExpanded}
641 contentInsetVertical={isAndroid ? 0 : undefined}
642 style={
643 isAndroid
644 ? isExpanded
645 ? { minHeight: 80, maxHeight: 160, paddingHorizontal: 4, paddingVertical: 4 }
646 : { height: 36 }
647 : { flex: 1, minHeight: 0 }
648 }
649 textStyle={
650 isAndroid
651 ? { ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }
652 : headlineText
653 }
654 />
655 );
656
657 const toolbarPills = (
658 <>
659 <ComposerToolbarButton
660 icon="plus"
661 onPress={() => void handlePickImages()}
662 showChevron={false}
663 />
664 <ControlPillMenu
665 actions={modelMenuActions}
666 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
667 >
668 <ComposerToolbarTrigger
669 accessibilityLabel="Model"
670 iconNode={<ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />}
671 label={flow.selectedModelOption?.label ?? "Model"}
672 />
673 </ControlPillMenu>
674 <ControlPillMenu
675 actions={optionsMenuActions}
676 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
677 >
678 <ComposerToolbarTrigger
679 accessibilityLabel="Configuration"
680 icon="slider.horizontal.3"
681 label={configurationLabel}
682 />
683 </ControlPillMenu>
684 <ControlPillMenu
685 actions={environmentMenuActions}
686 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}
687 >
688 <ComposerToolbarTrigger
689 accessibilityLabel="Environment"
690 icon="desktopcomputer"
691 label={selectedEnvironmentLabel}
692 />
693 </ControlPillMenu>
694 <ControlPillMenu
695 actions={workspaceMenuActions}
696 onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}
697 >
698 <ComposerToolbarTrigger
699 accessibilityLabel="Workspace"
700 icon="point.topleft.down.curvedto.point.bottomright.up"
701 label={workspaceLabel}
702 />
703 </ControlPillMenu>
704 </>
705 );
706
707 const startButton = (
708 <ComposerToolbarButton
709 accessibilityLabel={
710 flow.submitting ? "Starting task" : environmentConnected ? "Start task" : "Queue task"
711 }
712 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
713 onPress={() => void handleStart()}
714 variant="primary"
715 showChevron={false}
716 disabled={!canStart}
717 />
718 );
719
720 if (isAndroid) {
721 // The draft is a thread that doesn't exist yet, so it mirrors the thread
722 // page: in-screen header, empty feed canvas above, and the same floating
723 // composer chrome as ThreadComposer (collapsed pill → expanded card).
724 return (
725 <View className="flex-1 bg-screen">
726 <NativeStackScreenOptions options={{ headerShown: false }} />
727 <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} />
728
729 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">
730 <View className="flex-1" />
731
732 <View
733 className="px-4 pt-2"
734 style={{
735 paddingBottom: controlsBottomPadding,
736 experimental_backgroundImage: isDarkMode
737 ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)"
738 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",
739 }}
740 >
741 <ComposerSurface
742 isDarkMode={isDarkMode}
743 style={
744 isExpanded
745 ? {
746 borderRadius: 20,
747 overflow: "hidden",
748 paddingHorizontal: 14,
749 paddingVertical: 12,
750 }
751 : {
752 borderRadius: 999,
753 overflow: "hidden",
754 flexDirection: "row",
755 alignItems: "center",
756 paddingLeft: 18,
757 paddingRight: 5,
758 paddingVertical: 5,
759 }
760 }
761 >
762 {isExpanded && flow.attachments.length > 0 ? (
763 <View className="pb-2.5">
764 <ComposerAttachmentStrip
765 attachments={flow.attachments}
766 onRemove={flow.removeAttachment}
767 />
768 </View>
769 ) : null}
770 <View className={isExpanded ? undefined : "min-w-0 flex-1"}>{promptEditor}</View>
771 {!isExpanded ? (
772 <ControlPill
773 icon="arrow.up"
774 variant="primary"
775 disabled={!canStart}
776 onPress={() => void handleStart()}
777 />
778 ) : null}
779 </ComposerSurface>
780
781 {isExpanded ? (
782 <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
783 <ComposerToolbarScroller
784 fadeOpaque={isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"}
785 fadeTransparent={isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"}
786 >
787 {toolbarPills}
788 </ComposerToolbarScroller>
789 {startButton}
790 </ComposerToolbarRow>
791 ) : null}
792 </View>
793 </KeyboardAvoidingView>
593 </View>794 </View>
594 );795 );
595 }796 }
...@@ -599,21 +800,7 @@ export function NewTaskDraftScreen(props: {...@@ -599,21 +800,7 @@ export function NewTaskDraftScreen(props: {
599 <NativeStackScreenOptions options={{ title: selectedProject.title }} />800 <NativeStackScreenOptions options={{ title: selectedProject.title }} />
600801
601 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">802 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">
602 <View className="min-h-0 flex-1 px-5 pt-2">803 <View className="min-h-0 flex-1 px-5 pt-2">{promptEditor}</View>
603 <ComposerEditor
604 ref={promptInputRef}
605 autoFocus
606 multiline
607 scrollEnabled
608 value={flow.prompt}
609 skills={flow.selectedProviderSkills}
610 onChangeText={flow.setPrompt}
611 onPasteImages={(uris) => void handleNativePasteImages(uris)}
612 placeholder={`Describe a coding task in ${selectedProject.title}`}
613 style={{ flex: 1, minHeight: 0 }}
614 textStyle={headlineText}
615 />
616 </View>
617804
618 <View className="border-t border-border" style={{ paddingBottom: controlsBottomPadding }}>805 <View className="border-t border-border" style={{ paddingBottom: controlsBottomPadding }}>
619 {flow.attachments.length > 0 ? (806 {flow.attachments.length > 0 ? (
...@@ -631,74 +818,9 @@ export function NewTaskDraftScreen(props: {...@@ -631,74 +818,9 @@ export function NewTaskDraftScreen(props: {
631 fadeOpaque={sheetFadeOpaque}818 fadeOpaque={sheetFadeOpaque}
632 fadeTransparent={sheetFadeTransparent}819 fadeTransparent={sheetFadeTransparent}
633 >820 >
634 <ComposerToolbarButton821 {toolbarPills}
635 icon="plus"
636 onPress={() => void handlePickImages()}
637 showChevron={false}
638 />
639 <ControlPillMenu
640 actions={modelMenuActions}
641 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
642 >
643 <ComposerToolbarTrigger
644 accessibilityLabel="Model"
645 iconNode={
646 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />
647 }
648 label={flow.selectedModelOption?.label ?? "Model"}
649 />
650 </ControlPillMenu>
651 <ControlPillMenu
652 actions={optionsMenuActions}
653 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
654 >
655 <ComposerToolbarTrigger
656 accessibilityLabel="Configuration"
657 icon="slider.horizontal.3"
658 label={configurationLabel}
659 />
660 </ControlPillMenu>
661 <ControlPillMenu
662 actions={environmentMenuActions}
663 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}
664 >
665 <ComposerToolbarTrigger
666 accessibilityLabel="Environment"
667 icon="desktopcomputer"
668 label={selectedEnvironmentLabel}
669 />
670 </ControlPillMenu>
671 <ControlPillMenu
672 actions={workspaceMenuActions}
673 onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}
674 >
675 <ComposerToolbarTrigger
676 accessibilityLabel="Workspace"
677 icon="point.topleft.down.curvedto.point.bottomright.up"
678 label={workspaceLabel}
679 />
680 </ControlPillMenu>
681 </ComposerToolbarScroller>822 </ComposerToolbarScroller>
682 <ComposerToolbarButton823 {startButton}
683 accessibilityLabel={
684 flow.submitting
685 ? "Starting task"
686 : environmentConnected
687 ? "Start task"
688 : "Queue task"
689 }
690 icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"}
691 onPress={() => void handleStart()}
692 variant="primary"
693 showChevron={false}
694 disabled={
695 !flow.selectedProject ||
696 !flow.selectedModel ||
697 flow.prompt.trim().length === 0 ||
698 flow.submitting ||
699 (flow.workspaceMode === "worktree" && !flow.selectedBranchName)
700 }
701 />
702 </ComposerToolbarRow>824 </ComposerToolbarRow>
703 </View>825 </View>
704 </KeyboardAvoidingView>826 </KeyboardAvoidingView>
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+34-15
...@@ -1,13 +1,14 @@...@@ -1,13 +1,14 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { useNavigation } from "@react-navigation/native";2import { useNavigation } from "@react-navigation/native";
3import { SymbolView } from "expo-symbols";3import { SymbolView } from "../../components/AppSymbol";
4import type { EnvironmentId, ProjectId } from "@t3tools/contracts";4import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
5import { useMemo } from "react";5import { useMemo } from "react";
6import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";6import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import { useThemeColor } from "../../lib/useThemeColor";8import { useThemeColor } from "../../lib/useThemeColor";
9import { cn } from "../../lib/cn";9import { cn } from "../../lib/cn";
1010
11import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
11import { AppText as Text } from "../../components/AppText";12import { AppText as Text } from "../../components/AppText";
12import { ProjectFavicon } from "../../components/ProjectFavicon";13import { ProjectFavicon } from "../../components/ProjectFavicon";
13import { useProjects, useThreadShells } from "../../state/entities";14import { useProjects, useThreadShells } from "../../state/entities";
...@@ -111,21 +112,39 @@ export function NewTaskRouteScreen() {...@@ -111,21 +112,39 @@ export function NewTaskRouteScreen() {
111112
112 return (113 return (
113 <View collapsable={false} className="flex-1 bg-sheet">114 <View collapsable={false} className="flex-1 bg-sheet">
114 <NativeHeaderToolbar placement="right">115 {Platform.OS === "android" ? (
115 {layout.usesSplitView ? (116 <>
117 {/* Android renders its own in-screen header instead of the native bar. */}
118 <NativeStackScreenOptions options={{ headerShown: false }} />
119 <AndroidScreenHeader
120 title="Choose project"
121 onBack={layout.usesSplitView ? () => navigation.goBack() : undefined}
122 actions={[
123 {
124 accessibilityLabel: "Add project",
125 icon: "plus",
126 onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }),
127 },
128 ]}
129 />
130 </>
131 ) : (
132 <NativeHeaderToolbar placement="right">
133 {layout.usesSplitView ? (
134 <NativeHeaderToolbar.Button
135 accessibilityLabel="Close new task"
136 icon="xmark"
137 onPress={() => navigation.goBack()}
138 separateBackground
139 />
140 ) : null}
116 <NativeHeaderToolbar.Button141 <NativeHeaderToolbar.Button
117 accessibilityLabel="Close new task"142 icon="plus"
118 icon="xmark"143 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
119 onPress={() => navigation.goBack()}
120 separateBackground144 separateBackground
121 />145 />
122 ) : null}146 </NativeHeaderToolbar>
123 <NativeHeaderToolbar.Button147 )}
124 icon="plus"
125 onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
126 separateBackground
127 />
128 </NativeHeaderToolbar>
129148
130 <ScrollView149 <ScrollView
131 contentInsetAdjustmentBehavior="automatic"150 contentInsetAdjustmentBehavior="automatic"
apps/mobile/src/features/threads/ThreadComposer.tsx+17-5
...@@ -19,6 +19,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject...@@ -19,6 +19,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject
19import {19import {
20 ActivityIndicator,20 ActivityIndicator,
21 Image,21 Image,
22 Platform,
22 Pressable,23 Pressable,
23 useColorScheme,24 useColorScheme,
24 View,25 View,
...@@ -113,12 +114,18 @@ export interface ThreadComposerProps {...@@ -113,12 +114,18 @@ export interface ThreadComposerProps {
113/**114/**
114 * The pill / card container — renders as LiquidGlassView on supported115 * The pill / card container — renders as LiquidGlassView on supported
115 * iOS 26+ devices (progressive blur, native morph), opaque View otherwise.116 * iOS 26+ devices (progressive blur, native morph), opaque View otherwise.
117 * Exported so NewTaskDraftScreen can render the same composer chrome.
116 */118 */
117// One timing for every piece of the expanded↔compact morph so the surface,119// One timing for every piece of the expanded↔compact morph so the surface,
118// toolbar, and siblings move together instead of popping between layouts.120// toolbar, and siblings move together instead of popping between layouts.
119const COMPOSER_LAYOUT_TRANSITION = LinearTransition.duration(220);121// Android gets NO layout transition: the composer rides the keyboard via
120122// KeyboardStickyView (frame-synced to the IME), and a time-based morph
121function ComposerSurface(props: {123// running alongside that translate reads as jitter. Snapping the layout and
124// letting the keyboard-synced slide be the only motion looks native there.
125const COMPOSER_LAYOUT_TRANSITION =
126 Platform.OS === "android" ? undefined : LinearTransition.duration(220);
127
128export function ComposerSurface(props: {
122 readonly children: ReactNode;129 readonly children: ReactNode;
123 readonly style: ViewStyle;130 readonly style: ViewStyle;
124 readonly isDarkMode: boolean;131 readonly isDarkMode: boolean;
...@@ -772,7 +779,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -772,7 +779,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
772 onBlur={handleBlur}779 onBlur={handleBlur}
773 onSubmit={handleSend}780 onSubmit={handleSend}
774 scrollEnabled={isExpanded}781 scrollEnabled={isExpanded}
775 contentInsetVertical={isExpanded ? 0 : 6}782 // Android: collapsed single line centers natively (gravity) in
783 // a pill-height box matching the send button; iOS keeps insets.
784 singleLineCentered={!isExpanded}
785 contentInsetVertical={isExpanded || Platform.OS === "android" ? 0 : 6}
776 style={786 style={
777 isExpanded787 isExpanded
778 ? {788 ? {
...@@ -827,8 +837,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -827,8 +837,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
827 ) : null}837 ) : null}
828 </ComposerSurface>838 </ComposerSurface>
829839
830 {/* Toolbar row — matches draft page layout (expanded only) */}
831 {isExpanded ? (840 {isExpanded ? (
841 // Toolbar row — matches draft page layout (expanded only)
832 <Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)}>842 <Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)}>
833 <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>843 <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
834 <ComposerToolbarScroller844 <ComposerToolbarScroller
...@@ -836,6 +846,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -836,6 +846,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
836 fadeTransparent={toolbarFadeTransparent}846 fadeTransparent={toolbarFadeTransparent}
837 >847 >
838 <ComposerToolbarButton848 <ComposerToolbarButton
849 accessibilityLabel="Add attachment"
839 icon="plus"850 icon="plus"
840 onPress={() => void props.onPickDraftImages()}851 onPress={() => void props.onPickDraftImages()}
841 showChevron={false}852 showChevron={false}
...@@ -864,6 +875,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -864,6 +875,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
864 </ControlPillMenu>875 </ControlPillMenu>
865 {showStopAction ? (876 {showStopAction ? (
866 <ComposerToolbarButton877 <ComposerToolbarButton
878 accessibilityLabel="Stop"
867 icon="stop.fill"879 icon="stop.fill"
868 variant="danger"880 variant="danger"
869 onPress={props.onStopThread}881 onPress={props.onStopThread}
apps/mobile/src/features/threads/ThreadFeed.tsx+154-49
...@@ -3,7 +3,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";...@@ -3,7 +3,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { type LegendListRef } from "@legendapp/list/react-native";3import { type LegendListRef } from "@legendapp/list/react-native";
4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
6import { SymbolView } from "expo-symbols";6import { SymbolView } from "../../components/AppSymbol";
7import { HeaderHeightContext } from "@react-navigation/elements";7import { HeaderHeightContext } from "@react-navigation/elements";
8import { useNavigation } from "@react-navigation/native";8import { useNavigation } from "@react-navigation/native";
9import {9import {
...@@ -47,7 +47,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -47,7 +47,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
47import Animated, {47import Animated, {
48 FadeIn,48 FadeIn,
49 FadeInUp,49 FadeInUp,
50 FadeOut,
51 LinearTransition,50 LinearTransition,
52 type SharedValue,51 type SharedValue,
53} from "react-native-reanimated";52} from "react-native-reanimated";
...@@ -67,6 +66,7 @@ import {...@@ -67,6 +66,7 @@ import {
67 parseReviewCommentMessageSegments,66 parseReviewCommentMessageSegments,
68 type ReviewInlineComment,67 type ReviewInlineComment,
69} from "../review/reviewCommentSelection";68} from "../review/reviewCommentSelection";
69import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";
70import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";70import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
71import {71import {
72 buildNativeReviewDiffData,72 buildNativeReviewDiffData,
...@@ -91,6 +91,7 @@ import {...@@ -91,6 +91,7 @@ import {
91} from "../../lib/threadActivity";91} from "../../lib/threadActivity";
92import type { ThreadContentPresentation } from "./threadContentPresentation";92import type { ThreadContentPresentation } from "./threadContentPresentation";
93import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log";93import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log";
94import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
94import { useAssetUrl } from "../../state/assets";95import { useAssetUrl } from "../../state/assets";
95import { resolveWorkspaceRelativeFilePath } from "../files/filePath";96import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
9697
...@@ -203,6 +204,12 @@ const MARKDOWN_COLORS = {...@@ -203,6 +204,12 @@ const MARKDOWN_COLORS = {
203 },204 },
204} as const;205} as const;
205206
207const MARKDOWN_MONO_FONT = Platform.select({
208 ios: "ui-monospace",
209 android: "monospace",
210 default: "monospace",
211});
212
206interface MarkdownStyleSets {213interface MarkdownStyleSets {
207 readonly user: MarkdownStyleSet;214 readonly user: MarkdownStyleSet;
208 readonly assistant: MarkdownStyleSet;215 readonly assistant: MarkdownStyleSet;
...@@ -275,6 +282,122 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {...@@ -275,6 +282,122 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
275 );282 );
276});283});
277284
285function MarkdownCodeBlock(props: {
286 readonly backgroundColor: string;
287 readonly borderColor: string;
288 readonly content: string;
289 readonly copyTintColor: ColorValue;
290 readonly headerTextColor: string;
291 readonly fontSize: number;
292 readonly highlightCode: boolean;
293 readonly language?: string | null;
294 readonly lineHeight: number;
295 readonly textColor: string;
296 readonly theme: ReviewDiffTheme;
297}) {
298 const content = props.content.replace(/\n$/, "");
299 const languageLabel = props.language?.trim() || "text";
300 const highlighted = useMarkdownCodeHighlight({
301 code: content,
302 enabled: props.highlightCode && Boolean(props.language?.trim()),
303 language: props.language,
304 theme: props.theme,
305 });
306 let tokenOffset = 0;
307
308 return (
309 <View
310 className="my-3 min-w-0 max-w-full self-stretch overflow-hidden rounded-lg border"
311 style={{ backgroundColor: props.backgroundColor, borderColor: props.borderColor }}
312 >
313 <View
314 className="flex-row items-center justify-between gap-2 border-b py-1 pr-1.5 pl-3.5"
315 style={{ borderBottomColor: props.borderColor }}
316 >
317 <NativeText
318 className="flex-1 font-mono uppercase opacity-70"
319 numberOfLines={1}
320 style={{
321 color: props.headerTextColor,
322 fontSize: props.fontSize,
323 ...(Platform.OS === "android" ? { includeFontPadding: false } : null),
324 }}
325 >
326 {languageLabel}
327 </NativeText>
328 <CopyTextButton
329 accessibilityLabel="Copy code"
330 text={content}
331 tintColor={props.copyTintColor}
332 buttonSize={32}
333 iconSize={16}
334 />
335 </View>
336 <ScrollView
337 horizontal
338 bounces={false}
339 nestedScrollEnabled={Platform.OS === "android"}
340 showsHorizontalScrollIndicator={false}
341 contentContainerClassName="px-3.5 py-3"
342 >
343 <NativeText
344 selectable
345 className="font-mono"
346 style={{
347 color: props.textColor,
348 fontSize: props.fontSize,
349 lineHeight: props.lineHeight,
350 ...(Platform.OS === "android" ? { includeFontPadding: false } : null),
351 }}
352 >
353 {highlighted
354 ? highlighted.map((line, lineIndex) => {
355 const lineStartOffset = tokenOffset;
356 const lineText = line.map((token) => token.content).join("");
357 const renderedLine = (
358 <NativeText key={`line:${lineStartOffset}:${lineText}`}>
359 {line.map((token) => {
360 const startOffset = tokenOffset;
361 tokenOffset += token.content.length;
362 const fontStyle =
363 token.fontStyle !== null && (token.fontStyle & 1) === 1
364 ? ("italic" as const)
365 : ("normal" as const);
366 const fontWeight =
367 token.fontStyle !== null && (token.fontStyle & 2) === 2
368 ? ("700" as const)
369 : ("400" as const);
370
371 return (
372 <NativeText
373 key={`${startOffset}:${token.content}:${token.color ?? ""}:${
374 token.fontStyle ?? ""
375 }`}
376 style={{
377 color: token.color ?? props.textColor,
378 fontStyle,
379 fontWeight,
380 }}
381 >
382 {token.content}
383 </NativeText>
384 );
385 })}
386 {lineIndex + 1 < highlighted.length ? "\n" : ""}
387 </NativeText>
388 );
389 if (lineIndex + 1 < highlighted.length) {
390 tokenOffset += 1;
391 }
392 return renderedLine;
393 })
394 : content}
395 </NativeText>
396 </ScrollView>
397 </View>
398 );
399}
400
278function useReviewCommentColors(): ReviewCommentColors {401function useReviewCommentColors(): ReviewCommentColors {
279 const colorScheme = useColorScheme();402 const colorScheme = useColorScheme();
280 const isDark = colorScheme === "dark";403 const isDark = colorScheme === "dark";
...@@ -309,8 +432,11 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -309,8 +432,11 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
309 () => resolveNativeMarkdownTypography(appearance.baseFontSize),432 () => resolveNativeMarkdownTypography(appearance.baseFontSize),
310 [appearance.baseFontSize],433 [appearance.baseFontSize],
311 );434 );
312 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];435 const themeMode = colorScheme === "dark" ? "dark" : "light";
436 const colors = MARKDOWN_COLORS[themeMode];
437 const iconSubtleColor = String(useThemeColor("--color-icon-subtle"));
313 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));438 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
439 const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted"));
314 const regularFontFamily = useFontFamily("regular");440 const regularFontFamily = useFontFamily("regular");
315 const boldFontFamily = useFontFamily("bold");441 const boldFontFamily = useFontFamily("bold");
316442
...@@ -338,6 +464,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -338,6 +464,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
338 link: markdownLinkColor,464 link: markdownLinkColor,
339 blockquote: markdownBlockquoteBorder,465 blockquote: markdownBlockquoteBorder,
340 border: markdownHrColor,466 border: markdownHrColor,
467 surface: "transparent",
341 surfaceLight: markdownBlockquoteBg,468 surfaceLight: markdownBlockquoteBg,
342 accent: markdownLinkColor,469 accent: markdownLinkColor,
343 tableBorder: markdownHrColor,470 tableBorder: markdownHrColor,
...@@ -366,7 +493,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -366,7 +493,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
366 fontFamilies: {493 fontFamilies: {
367 regular: regularFontFamily,494 regular: regularFontFamily,
368 heading: boldFontFamily,495 heading: boldFontFamily,
369 mono: "ui-monospace",496 mono: MARKDOWN_MONO_FONT,
370 },497 },
371 headingWeight: "700",498 headingWeight: "700",
372 borderRadius: {499 borderRadius: {
...@@ -420,7 +547,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -420,7 +547,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
420 inlineCodeTextColor: string,547 inlineCodeTextColor: string,
421 blockBackgroundColor: string,548 blockBackgroundColor: string,
422 blockTextColor: string,549 blockTextColor: string,
550 copyTintColor: ColorValue,
423 preserveSoftBreaks: boolean,551 preserveSoftBreaks: boolean,
552 highlightCode: boolean,
424 ): CustomRenderers => ({553 ): CustomRenderers => ({
425 link: ({ children, href = "" }) => {554 link: ({ children, href = "" }) => {
426 const presentation = resolveMarkdownLinkPresentation(href);555 const presentation = resolveMarkdownLinkPresentation(href);
...@@ -519,51 +648,20 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -519,51 +648,20 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
519 soft_break: () => <NativeText>{"\n"}</NativeText>,648 soft_break: () => <NativeText>{"\n"}</NativeText>,
520 }649 }
521 : {}),650 : {}),
522 code_block: ({ content, language }) => (651 code_block: ({ content = "", language }) => (
523 <View652 <MarkdownCodeBlock
524 className="my-3 overflow-hidden rounded-[10px] border"653 backgroundColor={blockBackgroundColor}
525 style={{654 borderColor={markdownHrColor}
526 backgroundColor: blockBackgroundColor,655 content={content}
527 borderColor: markdownHrColor,656 copyTintColor={copyTintColor}
528 }}657 fontSize={markdownFontSizes.codeBlockFontSize}
529 >658 headerTextColor={blockTextColor}
530 {language ? (659 highlightCode={highlightCode}
531 <View660 language={language}
532 className="border-b px-3.5 py-2"661 lineHeight={markdownFontSizes.codeBlockLineHeight}
533 style={{662 textColor={blockTextColor}
534 borderBottomColor: markdownHrColor,663 theme={themeMode}
535 }}664 />
536 >
537 <NativeText
538 className="font-mono uppercase opacity-70"
539 style={{
540 color: markdownBodyColor,
541 fontSize: markdownFontSizes.codeBlockFontSize,
542 }}
543 >
544 {language}
545 </NativeText>
546 </View>
547 ) : null}
548 <ScrollView
549 horizontal
550 showsHorizontalScrollIndicator={false}
551 bounces={false}
552 contentContainerClassName="px-3.5 py-3"
553 >
554 <NativeText
555 selectable
556 className="font-mono"
557 style={{
558 color: blockTextColor,
559 fontSize: markdownFontSizes.codeBlockFontSize,
560 lineHeight: markdownFontSizes.codeBlockLineHeight,
561 }}
562 >
563 {content}
564 </NativeText>
565 </ScrollView>
566 </View>
567 ),665 ),
568 });666 });
569667
...@@ -621,7 +719,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -621,7 +719,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
621 markdownUserInlineCodeText,719 markdownUserInlineCodeText,
622 markdownUserFenceBg,720 markdownUserFenceBg,
623 markdownUserFenceText,721 markdownUserFenceText,
722 userBubbleForegroundMuted,
624 true,723 true,
724 false,
625 ),725 ),
626 nativeTextStyle: {726 nativeTextStyle: {
627 color: markdownUserBodyColor,727 color: markdownUserBodyColor,
...@@ -652,7 +752,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -652,7 +752,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
652 markdownInlineCodeText,752 markdownInlineCodeText,
653 markdownCodeBg,753 markdownCodeBg,
654 markdownCodeText,754 markdownCodeText,
755 iconSubtleColor,
655 false,756 false,
757 true,
656 ),758 ),
657 nativeTextStyle: {759 nativeTextStyle: {
658 color: markdownBodyColor,760 color: markdownBodyColor,
...@@ -679,11 +781,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe...@@ -679,11 +781,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe
679 }, [781 }, [
680 boldFontFamily,782 boldFontFamily,
681 colors,783 colors,
784 iconSubtleColor,
682 inlineSkillForeground,785 inlineSkillForeground,
683 markdownFontSizes,786 markdownFontSizes,
684 nativeMarkdownTypography,787 nativeMarkdownTypography,
685 onLinkPress,788 onLinkPress,
686 regularFontFamily,789 regularFontFamily,
790 themeMode,
791 userBubbleForegroundMuted,
687 ]);792 ]);
688}793}
689794
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+1-1
...@@ -5,7 +5,6 @@ import type {...@@ -5,7 +5,6 @@ import type {
5} from "@t3tools/client-runtime/state/shell";5} from "@t3tools/client-runtime/state/shell";
6import { LegendList } from "@legendapp/list/react-native";6import { LegendList } from "@legendapp/list/react-native";
7import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
8import { SymbolView } from "expo-symbols";
9import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";8import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
10import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";9import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
11import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native";10import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native";
...@@ -17,6 +16,7 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";...@@ -17,6 +16,7 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
1716
18import { AppText as Text } from "../../components/AppText";17import { AppText as Text } from "../../components/AppText";
19import { ControlPillMenu } from "../../components/ControlPill";18import { ControlPillMenu } from "../../components/ControlPill";
19import { SymbolView } from "../../components/AppSymbol";
20import { NativeStackScreenOptions } from "../../native/StackHeader";20import { NativeStackScreenOptions } from "../../native/StackHeader";
21import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";21import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
22import { useThemeColor } from "../../lib/useThemeColor";22import { useThemeColor } from "../../lib/useThemeColor";
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+91-9
...@@ -9,18 +9,20 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro...@@ -9,18 +9,20 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
9import * as Option from "effect/Option";9import * as Option from "effect/Option";
10import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";10import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
11import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";11import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
12import { Platform, Pressable, ScrollView, Text as RNText, View } from "react-native";12import { Platform, ScrollView, View } from "react-native";
13import { useSafeAreaInsets } from "react-native-safe-area-context";13import { useSafeAreaInsets } from "react-native-safe-area-context";
14import { useWorkspaceState } from "../../state/workspace";14import { useWorkspaceState } from "../../state/workspace";
15import { useThemeColor } from "../../lib/useThemeColor";
16import { useEnvironmentQuery } from "../../state/query";15import { useEnvironmentQuery } from "../../state/query";
17import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";16import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
18import { vcsEnvironment } from "../../state/vcs";17import { vcsEnvironment } from "../../state/vcs";
1918
20import { EmptyState } from "../../components/EmptyState";19import { EmptyState } from "../../components/EmptyState";
20import {
21 AndroidScreenHeader,
22 type AndroidHeaderAction,
23} from "../../components/AndroidScreenHeader";
21import { LoadingScreen } from "../../components/LoadingScreen";24import { LoadingScreen } from "../../components/LoadingScreen";
22import { scopedThreadKey } from "../../lib/scopedEntities";25import { scopedThreadKey } from "../../lib/scopedEntities";
23import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
24import { connectionTone } from "../connection/connectionTone";26import { connectionTone } from "../connection/connectionTone";
2527
26import {28import {
...@@ -68,7 +70,6 @@ import {...@@ -68,7 +70,6 @@ import {
68 ThreadInspectorContentStack,70 ThreadInspectorContentStack,
69 type ThreadInspectorMode,71 type ThreadInspectorMode,
70} from "./thread-inspector-content-stack";72} from "./thread-inspector-content-stack";
71import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
7273
73interface ThreadInspectorSelection {74interface ThreadInspectorSelection {
74 readonly routeThreadIdentity: string | null;75 readonly routeThreadIdentity: string | null;
...@@ -277,7 +278,6 @@ function ThreadRouteContent(...@@ -277,7 +278,6 @@ function ThreadRouteContent(
277 );278 );
278279
279 /* ─── Native header theming ──────────────────────────────────────── */280 /* ─── Native header theming ──────────────────────────────────────── */
280 const foregroundColor = String(useThemeColor("--color-foreground"));
281 const usesNativeHeaderGlass = Platform.OS === "ios";281 const usesNativeHeaderGlass = Platform.OS === "ios";
282 const headerSubtitle = [282 const headerSubtitle = [
283 selectedThreadProject?.title ?? null,283 selectedThreadProject?.title ?? null,
...@@ -325,11 +325,28 @@ function ThreadRouteContent(...@@ -325,11 +325,28 @@ function ThreadRouteContent(
325 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);325 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);
326326
327 const handleOpenGitInspector = useCallback(() => {327 const handleOpenGitInspector = useCallback(() => {
328 if (!fileInspector.supported) {
329 if (selectedThread === null) {
330 return;
331 }
332 navigation.navigate("GitOverview", {
333 environmentId: String(selectedThread.environmentId),
334 threadId: String(selectedThread.id),
335 });
336 return;
337 }
328 setInspectorSelection({ routeThreadIdentity, mode: "git" });338 setInspectorSelection({ routeThreadIdentity, mode: "git" });
329 showAuxiliaryPane("inspector");339 showAuxiliaryPane("inspector");
330 }, [routeThreadIdentity, showAuxiliaryPane]);340 }, [fileInspector.supported, navigation, routeThreadIdentity, selectedThread, showAuxiliaryPane]);
331 const handleOpenFilesInspector = useCallback(() => {341 const handleOpenFilesInspector = useCallback(() => {
332 if (!fileInspector.supported || selectedThread === null || selectedThreadCwd === null) {342 if (selectedThread === null || selectedThreadCwd === null) {
343 return;
344 }
345 if (!fileInspector.supported) {
346 navigation.navigate("ThreadFiles", {
347 environmentId: String(selectedThread.environmentId),
348 threadId: String(selectedThread.id),
349 });
333 return;350 return;
334 }351 }
335 setInspectorSelection({352 setInspectorSelection({
...@@ -339,6 +356,7 @@ function ThreadRouteContent(...@@ -339,6 +356,7 @@ function ThreadRouteContent(
339 showAuxiliaryPane("inspector");356 showAuxiliaryPane("inspector");
340 }, [357 }, [
341 fileInspector.supported,358 fileInspector.supported,
359 navigation,
342 props.renderInspector,360 props.renderInspector,
343 routeThreadIdentity,361 routeThreadIdentity,
344 selectedThread,362 selectedThread,
...@@ -641,6 +659,55 @@ function ThreadRouteContent(...@@ -641,6 +659,55 @@ function ThreadRouteContent(
641 ],659 ],
642 [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],660 [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
643 );661 );
662 const androidHeaderActions = useMemo<ReadonlyArray<AndroidHeaderAction>>(() => {
663 if (Platform.OS !== "android") return [];
664
665 const actions: AndroidHeaderAction[] = [];
666 if (props.onReturnToThread) {
667 actions.push({
668 accessibilityLabel: "Return to chat",
669 icon: "chevron.left",
670 onPress: props.onReturnToThread,
671 });
672 }
673 if (selectedThreadCwd !== null) {
674 actions.push({
675 accessibilityLabel: "Open files",
676 icon: "folder",
677 onPress: handleOpenFilesInspector,
678 });
679 }
680 if (selectedThreadProject?.workspaceRoot) {
681 actions.push({
682 accessibilityLabel: "Open terminal",
683 icon: "terminal",
684 onPress: () => handleOpenTerminal(null),
685 });
686 }
687 actions.push({
688 accessibilityLabel: "Open git controls",
689 icon: "point.topleft.down.curvedto.point.bottomright.up",
690 onPress: handleOpenGitInspector,
691 });
692 if (fileInspector.supported && selectedThreadCwd !== null) {
693 actions.push({
694 accessibilityLabel: "Toggle inspector",
695 icon: "sidebar.right",
696 onPress: handleToggleInspector,
697 });
698 }
699 return actions;
700 }, [
701 fileInspector.supported,
702 handleOpenFilesInspector,
703 handleOpenTerminal,
704 handleOpenGitInspector,
705 handleToggleInspector,
706 props.onReturnToThread,
707 selectedThreadCwd,
708 selectedThreadProject?.workspaceRoot,
709 ]);
710
644 // Deep links / cold starts land with Thread as the ONLY route, where the711 // Deep links / cold starts land with Thread as the ONLY route, where the
645 // native back button does not render. Provide an explicit Home escape for712 // native back button does not render. Provide an explicit Home escape for
646 // that case; when history exists the native back button is used instead.713 // that case; when history exists the native back button is used instead.
...@@ -666,7 +733,6 @@ function ThreadRouteContent(...@@ -666,7 +733,6 @@ function ThreadRouteContent(
666 return <OpeningThreadLoadingScreen />;733 return <OpeningThreadLoadingScreen />;
667 }734 }
668735
669 const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id);
670 const contentPresentation = projectThreadContentPresentation({736 const contentPresentation = projectThreadContentPresentation({
671 hasDetail: selectedThreadDetail !== null,737 hasDetail: selectedThreadDetail !== null,
672 detailError: Option.getOrNull(selectedThreadDetailState.error),738 detailError: Option.getOrNull(selectedThreadDetailState.error),
...@@ -732,6 +798,9 @@ function ThreadRouteContent(...@@ -732,6 +798,9 @@ function ThreadRouteContent(
732 {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null}798 {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null}
733 <NativeStackScreenOptions799 <NativeStackScreenOptions
734 options={{800 options={{
801 // Android draws its own in-flow header (AndroidScreenHeader below);
802 // the native stack header stays iOS-only.
803 headerShown: Platform.OS !== "android",
735 headerTitle: selectedThread.title,804 headerTitle: selectedThread.title,
736 headerTitleStyle: usesNativeHeaderGlass805 headerTitleStyle: usesNativeHeaderGlass
737 ? {806 ? {
...@@ -763,7 +832,20 @@ function ThreadRouteContent(...@@ -763,7 +832,20 @@ function ThreadRouteContent(
763 }}832 }}
764 />833 />
765834
766 {renderThreadRouteBody(!layout.usesSplitView && !usesNativeHeaderGlass)}835 {Platform.OS === "android" ? (
836 <AndroidScreenHeader
837 title={selectedThread.title}
838 subtitle={headerSubtitle}
839 onBack={layout.usesSplitView ? undefined : () => navigation.goBack()}
840 actions={androidHeaderActions}
841 />
842 ) : null}
843
844 {/* Android surfaces the git/files/inspector actions in its in-flow
845 header above, so the fallback action toolbar stays iOS-only. */}
846 {renderThreadRouteBody(
847 Platform.OS !== "android" && !layout.usesSplitView && !usesNativeHeaderGlass,
848 )}
767 </>849 </>
768 );850 );
769}851}
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+1-5
...@@ -60,11 +60,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {...@@ -60,11 +60,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
60 <ScrollView60 <ScrollView
61 showsVerticalScrollIndicator={false}61 showsVerticalScrollIndicator={false}
62 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}62 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
63 contentContainerStyle={{63 contentContainerClassName="gap-4 px-5 pt-2"
64 paddingHorizontal: 20,
65 paddingTop: 8,
66 gap: 16,
67 }}
68 >64 >
69 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">65 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
70 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">66 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+1-5
...@@ -69,11 +69,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {...@@ -69,11 +69,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
69 keyboardShouldPersistTaps="handled"69 keyboardShouldPersistTaps="handled"
70 showsVerticalScrollIndicator={false}70 showsVerticalScrollIndicator={false}
71 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}71 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
72 contentContainerStyle={{72 contentContainerClassName="gap-4 px-5 pt-2"
73 paddingHorizontal: 20,
74 paddingTop: 8,
75 gap: 16,
76 }}
77 >73 >
78 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">74 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
79 <View className="flex-row items-center justify-between gap-3">75 <View className="flex-row items-center justify-between gap-3">
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+1
...@@ -5,6 +5,7 @@ import * as Result from "effect/Result";...@@ -5,6 +5,7 @@ import * as Result from "effect/Result";
5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
6import { useCallback, useMemo } from "react";6import { useCallback, useMemo } from "react";
7import { View } from "react-native";7import { View } from "react-native";
8
8import { useSafeAreaInsets } from "react-native-safe-area-context";9import { useSafeAreaInsets } from "react-native-safe-area-context";
910
10import { AppText as Text } from "../../../components/AppText";11import { AppText as Text } from "../../../components/AppText";
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+49-35
...@@ -6,9 +6,10 @@ import {...@@ -6,9 +6,10 @@ import {
6} from "@t3tools/client-runtime/state/vcs";6} from "@t3tools/client-runtime/state/vcs";
7import { EnvironmentId, ThreadId } from "@t3tools/contracts";7import { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import { useNavigation, type StaticScreenProps } from "@react-navigation/native";8import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
9import { SymbolView } from "expo-symbols";9import { SymbolView } from "../../../components/AppSymbol";
10import { useCallback, useEffect, useMemo, useState } from "react";10import { useCallback, useEffect, useMemo, useState } from "react";
11import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";11import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
12
12import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";13import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
13import { useSafeAreaInsets } from "react-native-safe-area-context";14import { useSafeAreaInsets } from "react-native-safe-area-context";
14import { useThemeColor } from "../../../lib/useThemeColor";15import { useThemeColor } from "../../../lib/useThemeColor";
...@@ -354,40 +355,53 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -354,40 +355,53 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
354 }}355 }}
355 />356 />
356357
357 <View358 {isInspector ? (
358 className={359 <View className="gap-1 border-b border-border px-4 pb-4 pt-3">
359 isInspector360 <Pressable
360 ? "gap-1 border-b border-border px-4 pb-4 pt-3"361 className={
361 : "items-center gap-1 px-5 pb-3 pt-4"362 busy
362 }363 ? "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle opacity-[0.45]"
363 >364 : "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle"
364 <Pressable365 }
365 className={366 disabled={busy}
366 busy367 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}
367 ? "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle opacity-[0.45]"368 >
368 : "absolute right-3 top-4 z-[1] h-9 w-9 items-center justify-center rounded-full bg-subtle"369 <SymbolView
369 }370 name="arrow.clockwise"
370 disabled={busy}371 size={16}
371 onPress={() => void gitActions.refreshSelectedThreadGitStatus()}372 tintColor={iconColor}
372 >373 type="monochrome"
373 <SymbolView374 weight="medium"
374 name="arrow.clockwise"375 />
375 size={16}376 </Pressable>
376 tintColor={iconColor}377 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">
377 type="monochrome"378 Repository
378 weight="medium"379 </Text>
379 />380 <Text className="pr-10 text-xl font-t3-bold">{currentBranchLabel}</Text>
380 </Pressable>381 <Text className="text-foreground-secondary text-sm font-medium leading-normal">
381 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">382 {currentStatusSummary}
382 {isInspector ? "Repository" : "Branch"}383 </Text>
383 </Text>384 </View>
384 <Text className={isInspector ? "pr-10 text-xl font-t3-bold" : "text-3xl font-t3-bold"}>385 ) : (
385 {currentBranchLabel}386 // Compact header row: labeled branch on the left, status summary at
386 </Text>387 // the trailing end. Horizontal padding lines the text up with the
387 <Text className="text-foreground-secondary text-sm font-medium leading-normal">388 // rows' icon column inside the card below (20 screen + 16 card + 4
388 {currentStatusSummary}389 // row). The sheet relies on pull-to-refresh instead of a corner
389 </Text>390 // refresh button.
390 </View>391 <View className="flex-row items-end justify-between gap-3 px-10 pb-4 pt-4">
392 <View className="shrink gap-0.5">
393 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">
394 Branch
395 </Text>
396 <Text className="text-xl font-t3-bold" numberOfLines={1}>
397 {currentBranchLabel}
398 </Text>
399 </View>
400 <Text className="text-foreground-secondary pb-0.5 text-sm font-medium" numberOfLines={1}>
401 {currentStatusSummary}
402 </Text>
403 </View>
404 )}
391405
392 {content}406 {content}
393 </View>407 </View>
apps/mobile/src/features/threads/git/gitSheetComponents.tsx+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../../../components/AppSymbol";
2import type { ComponentProps } from "react";2import type { ComponentProps } from "react";
3import { Pressable, View } from "react-native";3import { Pressable, View } from "react-native";
4import { useThemeColor } from "../../../lib/useThemeColor";4import { useThemeColor } from "../../../lib/useThemeColor";
apps/mobile/src/features/threads/markdownCodeHighlightState.ts created+87
...@@ -0,0 +1,87 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";
5import { useMemo } from "react";
6
7import {
8 highlightCodeSnippet,
9 type ReviewDiffTheme,
10 type ReviewHighlightedToken,
11} from "../review/shikiReviewHighlighter";
12
13const MARKDOWN_CODE_HIGHLIGHT_IDLE_TTL_MS = 5 * 60_000;
14
15export type MarkdownHighlightedCode = ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>;
16
17export interface MarkdownCodeHighlightInput {
18 readonly code: string;
19 readonly enabled: boolean;
20 readonly language: string;
21 readonly theme: ReviewDiffTheme;
22}
23
24type MarkdownCodeHighlighter = (
25 input: MarkdownCodeHighlightInput,
26) => Promise<MarkdownHighlightedCode | null>;
27
28class MarkdownCodeHighlightCacheKey extends Data.Class<MarkdownCodeHighlightInput> {}
29
30class MarkdownCodeHighlightError extends Data.TaggedError("MarkdownCodeHighlightError")<{
31 readonly cause: unknown;
32}> {}
33
34export function createMarkdownCodeHighlightAtomFamily(options?: {
35 readonly highlight?: MarkdownCodeHighlighter;
36 readonly idleTtlMs?: number;
37}) {
38 const highlight =
39 options?.highlight ??
40 ((input: MarkdownCodeHighlightInput) =>
41 input.enabled
42 ? highlightCodeSnippet({
43 code: input.code,
44 language: input.language,
45 theme: input.theme,
46 })
47 : Promise.resolve(null));
48 const idleTtlMs = options?.idleTtlMs ?? MARKDOWN_CODE_HIGHLIGHT_IDLE_TTL_MS;
49 const family = Atom.family((request: MarkdownCodeHighlightCacheKey) =>
50 Atom.make(
51 Effect.tryPromise({
52 try: () => highlight(request),
53 catch: (cause) => new MarkdownCodeHighlightError({ cause }),
54 }),
55 ).pipe(
56 Atom.setIdleTTL(idleTtlMs),
57 Atom.withLabel(`mobile:thread-markdown-code-highlight:${request.theme}:${request.language}`),
58 ),
59 );
60
61 return (input: MarkdownCodeHighlightInput) => family(new MarkdownCodeHighlightCacheKey(input));
62}
63
64export const markdownCodeHighlightAtom = createMarkdownCodeHighlightAtomFamily();
65
66export function useMarkdownCodeHighlight(input: {
67 readonly code: string;
68 readonly enabled: boolean;
69 readonly language: string | null | undefined;
70 readonly theme: ReviewDiffTheme;
71}): MarkdownHighlightedCode | null {
72 const normalizedLanguage = input.language?.trim() || "text";
73 const enabled = input.enabled && Boolean(input.language?.trim());
74 const atomLanguage = enabled ? normalizedLanguage : "text";
75 const highlightAtom = useMemo(
76 () =>
77 markdownCodeHighlightAtom({
78 code: enabled ? input.code : "",
79 enabled,
80 language: atomLanguage,
81 theme: input.theme,
82 }),
83 [atomLanguage, enabled, input.code, input.theme],
84 );
85 const result = useAtomValue(highlightAtom);
86 return AsyncResult.isSuccess(result) ? result.value : null;
87}
apps/mobile/src/features/threads/sidebar-filter-button.tsx+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../../components/AppSymbol";
2import { Pressable, StyleSheet, useColorScheme } from "react-native";2import { Pressable, StyleSheet, useColorScheme } from "react-native";
33
4import { useThemeColor } from "../../lib/useThemeColor";4import { useThemeColor } from "../../lib/useThemeColor";
apps/mobile/src/features/threads/sidebar-header-actions.android.tsx created+16
...@@ -0,0 +1,16 @@
1import { View } from "react-native";
2
3import { T3HeaderButton } from "../../native/T3HeaderButton.android";
4import type { SidebarHeaderActionsProps } from "./sidebar-header-actions";
5
6export function SidebarHeaderActions(props: SidebarHeaderActionsProps) {
7 return (
8 <View className="h-11 flex-row gap-1">
9 <T3HeaderButton
10 accessibilityLabel="Open settings"
11 icon="gearshape"
12 onPress={props.onOpenSettings}
13 />
14 </View>
15 );
16}
apps/mobile/src/features/threads/sidebar-header-actions.tsx+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SymbolView } from "expo-symbols";1import { SymbolView } from "../../components/AppSymbol";
2import { Pressable, StyleSheet, View, useColorScheme } from "react-native";2import { Pressable, StyleSheet, View, useColorScheme } from "react-native";
33
4import { useThemeColor } from "../../lib/useThemeColor";4import { useThemeColor } from "../../lib/useThemeColor";
apps/mobile/src/features/threads/thread-list-items.tsx+10-7
...@@ -4,7 +4,7 @@ import type {...@@ -4,7 +4,7 @@ import type {
4 EnvironmentThreadShell,4 EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";5} from "@t3tools/client-runtime/state/shell";
6import type { MenuAction } from "@react-native-menu/menu";6import type { MenuAction } from "@react-native-menu/menu";
7import { SymbolView } from "expo-symbols";7import { SymbolView } from "../../components/AppSymbol";
8import { memo, useCallback, useMemo, type ComponentProps } from "react";8import { memo, useCallback, useMemo, type ComponentProps } from "react";
9import { Pressable, useColorScheme, useWindowDimensions, View } from "react-native";9import { Pressable, useColorScheme, useWindowDimensions, View } from "react-native";
10import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";10import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
...@@ -131,6 +131,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:...@@ -131,6 +131,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
131 >131 >
132 <ProjectFavicon132 <ProjectFavicon
133 environmentId={props.project.environmentId}133 environmentId={props.project.environmentId}
134 open={!props.collapsed}
134 size={compact ? 22 : 18}135 size={compact ? 22 : 18}
135 projectTitle={props.project.title}136 projectTitle={props.project.title}
136 workspaceRoot={props.project.workspaceRoot}137 workspaceRoot={props.project.workspaceRoot}
...@@ -644,12 +645,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -644,12 +645,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
644 threadTitle={thread.title}645 threadTitle={thread.title}
645 >646 >
646 {(close) => (647 {(close) => (
647 // Messages-style row actions: a real UIContextMenuInteraction on648 // Messages-style row actions on long-press. iOS: a real
648 // long-press / pointer right-click, with the row as the zoom preview.649 // UIContextMenuInteraction with the row as the zoom preview (needs the
649 // Requires the patched @react-native-menu (see650 // patched @react-native-menu, see
650 // patches/@react-native-menu__menu@2.0.0.patch): in long-press mode651 // patches/@react-native-menu__menu@2.0.0.patch — in long-press mode the
651 // the interaction is hosted by the component view and the underlying652 // interaction is hosted by the component view and the underlying
652 // UIButton passes touches through, so row taps keep working.653 // UIButton passes touches through, so row taps keep working). Android:
654 // ControlPillMenu injects onLongPress into the row and anchors the
655 // token-styled dropdown to it; taps and swipes are untouched.
653 <ControlPillMenu656 <ControlPillMenu
654 actions={THREAD_ROW_MENU_ACTIONS}657 actions={THREAD_ROW_MENU_ACTIONS}
655 onPressAction={handleMenuAction}658 onPressAction={handleMenuAction}
apps/mobile/src/features/threads/thread-work-log.tsx+27-19
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { SymbolView, type SFSymbol } from "expo-symbols";2import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
3import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native";3import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native";
44
5import { AppText as Text } from "../../components/AppText";5import { AppText as Text } from "../../components/AppText";
...@@ -40,32 +40,32 @@ function compactActivityDetail(detail: string | null): string | null {...@@ -40,32 +40,32 @@ function compactActivityDetail(detail: string | null): string | null {
40 return cleaned.length > 0 ? cleaned : null;40 return cleaned.length > 0 ? cleaned : null;
41}41}
4242
43function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol {43function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {
44 switch (icon) {44 switch (icon) {
45 case "agent":45 case "agent":
46 return "sparkles";46 return { ios: "sparkles", android: "auto_awesome" };
47 case "alert":47 case "alert":
48 return "exclamationmark.triangle";48 return { ios: "exclamationmark.triangle", android: "error" };
49 case "check":49 case "check":
50 return "checkmark";50 return { ios: "checkmark", android: "check" };
51 case "command":51 case "command":
52 return "terminal";52 return { ios: "terminal", android: "terminal" };
53 case "edit":53 case "edit":
54 return "square.and.pencil";54 return { ios: "square.and.pencil", android: "edit" };
55 case "eye":55 case "eye":
56 return "eye";56 return { ios: "eye", android: "visibility" };
57 case "globe":57 case "globe":
58 return "globe";58 return { ios: "globe", android: "public" };
59 case "hammer":59 case "hammer":
60 return "hammer";60 return { ios: "hammer", android: "construction" };
61 case "message":61 case "message":
62 return "bubble.left";62 return { ios: "bubble.left", android: "chat_bubble" };
63 case "warning":63 case "warning":
64 return "xmark";64 return { ios: "xmark", android: "close" };
65 case "wrench":65 case "wrench":
66 return "wrench";66 return { ios: "wrench", android: "build" };
67 case "zap":67 case "zap":
68 return "bolt";68 return { ios: "bolt", android: "bolt" };
69 }69 }
70}70}
7171
...@@ -173,7 +173,11 @@ export function ThreadWorkLog(props: {...@@ -173,7 +173,11 @@ export function ThreadWorkLog(props: {
173 <View className="h-4 w-4 items-center justify-center">173 <View className="h-4 w-4 items-center justify-center">
174 {canExpand ? (174 {canExpand ? (
175 <SymbolView175 <SymbolView
176 name={expanded ? "chevron.up" : "chevron.down"}176 name={
177 expanded
178 ? { ios: "chevron.up", android: "keyboard_arrow_up" }
179 : { ios: "chevron.down", android: "keyboard_arrow_down" }
180 }
177 size={11}181 size={11}
178 tintColor={props.iconSubtleColor}182 tintColor={props.iconSubtleColor}
179 type="monochrome"183 type="monochrome"
...@@ -185,10 +189,10 @@ export function ThreadWorkLog(props: {...@@ -185,10 +189,10 @@ export function ThreadWorkLog(props: {
185 <SymbolView189 <SymbolView
186 name={190 name={
187 row.status === "failure"191 row.status === "failure"
188 ? "xmark"192 ? { ios: "xmark", android: "close" }
189 : row.status === "success"193 : row.status === "success"
190 ? "checkmark"194 ? { ios: "checkmark", android: "check" }
191 : "minus"195 : { ios: "minus", android: "remove" }
192 }196 }
193 size={11}197 size={11}
194 tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}198 tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
...@@ -265,7 +269,11 @@ export function ThreadWorkGroupToggle(props: {...@@ -265,7 +269,11 @@ export function ThreadWorkGroupToggle(props: {
265 >269 >
266 <View className="h-[18px] w-5 items-center justify-center">270 <View className="h-[18px] w-5 items-center justify-center">
267 <SymbolView271 <SymbolView
268 name={props.expanded ? "chevron.up" : "chevron.down"}272 name={
273 props.expanded
274 ? { ios: "chevron.up", android: "keyboard_arrow_up" }
275 : { ios: "chevron.down", android: "keyboard_arrow_down" }
276 }
269 size={12}277 size={12}
270 tintColor={props.iconSubtleColor}278 tintColor={props.iconSubtleColor}
271 type="monochrome"279 type="monochrome"
apps/mobile/src/lib/appBlurTarget.ts created+8
...@@ -0,0 +1,8 @@
1import { createRef } from "react";
2import type { View } from "react-native";
3
4// Android BlurViews can't sample their real backdrop like iOS does — they
5// need an explicit BlurTargetView to snapshot. App.tsx wraps the app content
6// in one and binds this ref so overlays hosted in separate windows (the
7// anchored dropdown's Modal) can still blur what's on screen behind them.
8export const appBlurTargetRef = createRef<View>();
apps/mobile/src/native/T3ComposerEditor.android.tsx created+6
...@@ -0,0 +1,6 @@
1export { ComposerEditor } from "./T3ComposerEditor.native";
2export type {
3 ComposerEditorHandle,
4 ComposerEditorProps,
5 ComposerEditorSelection,
6} from "./T3ComposerEditor.types";
apps/mobile/src/native/T3ComposerEditor.native.tsx created+286
...@@ -0,0 +1,286 @@
1import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
2import { requireNativeView } from "expo";
3import {
4 useCallback,
5 useEffect,
6 useImperativeHandle,
7 useMemo,
8 useRef,
9 useState,
10 type Ref,
11} from "react";
12import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native";
13import { Image, StyleSheet } from "react-native";
14
15import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
16import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
17import { MOBILE_TYPOGRAPHY } from "../lib/typography";
18import { useFontFamily } from "../lib/useFontFamily";
19import { useThemeColor } from "../lib/useThemeColor";
20import {
21 acknowledgeComposerNativeEvent,
22 isComposerNativeEcho,
23 pruneAcknowledgedComposerNativeEvents,
24 resolveComposerControlledEventCount,
25 type ComposerNativeEventSnapshot,
26} from "./composerEditorRevision";
27import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types";
28
29const NATIVE_MODULE_NAME = "T3ComposerEditor";
30const EMPTY_SKILLS: NonNullable<ComposerEditorProps["skills"]> = [];
31
32type NativeEditorEvent = NativeSyntheticEvent<{
33 readonly value: string;
34 readonly selection: ComposerEditorSelection;
35 readonly eventCount: number;
36}>;
37
38type NativeSelectionEvent = NativeSyntheticEvent<{
39 readonly value: string;
40 readonly selection: ComposerEditorSelection;
41 readonly eventCount: number;
42}>;
43
44type NativePasteImagesEvent = NativeSyntheticEvent<{
45 readonly uris: ReadonlyArray<string>;
46}>;
47
48interface NativeComposerEditorRef {
49 focus: () => Promise<void>;
50 blur: () => Promise<void>;
51 setSelection: (start: number, end: number) => Promise<void>;
52}
53
54interface NativeComposerEditorProps extends ViewProps {
55 readonly ref?: Ref<NativeComposerEditorRef>;
56 readonly controlledDocumentJson: string;
57 readonly themeJson: string;
58 readonly placeholder: string;
59 readonly fontFamily: string;
60 readonly fontSize: number;
61 readonly lineHeight: number;
62 readonly contentInsetVertical: number;
63 readonly singleLineCentered: boolean;
64 readonly editable: boolean;
65 readonly scrollEnabled: boolean;
66 readonly autoFocus: boolean;
67 readonly autoCorrect: boolean;
68 readonly spellCheck: boolean;
69 readonly onComposerChange: (event: NativeEditorEvent) => void;
70 readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void;
71 readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void;
72 readonly onComposerFocus?: () => void;
73 readonly onComposerBlur?: () => void;
74}
75
76const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME);
77
78function basename(path: string): string {
79 const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
80 return separator >= 0 ? path.slice(separator + 1) : path;
81}
82
83function fileIconUri(path: string): string {
84 return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri;
85}
86
87export function ComposerEditor({
88 ref,
89 skills = EMPTY_SKILLS,
90 selection,
91 style,
92 textStyle,
93 onChangeText,
94 onSelectionChange,
95 onPasteImages,
96 onFocus,
97 onBlur,
98 contentInsetVertical = 0,
99 ...props
100}: ComposerEditorProps) {
101 const nativeRef = useRef<NativeComposerEditorRef>(null);
102 const mostRecentEventCountRef = useRef(0);
103 const [mostRecentEventCount, setMostRecentEventCount] = useState(0);
104 const [nativeEventSequence, setNativeEventSequence] = useState(0);
105 const previousRenderedEventSequenceRef = useRef(0);
106 const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([
107 { eventCount: 0, value: props.value, selection: selection ?? null },
108 ]);
109 const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value));
110 const confirmedTokensRef = useRef(initialConfirmedTokens);
111 const textColor = useThemeColor("--color-foreground");
112 const placeholderColor = useThemeColor("--color-placeholder");
113 const chipBackground = useThemeColor("--color-subtle");
114 const chipBorder = useThemeColor("--color-border");
115 const chipText = useThemeColor("--color-foreground");
116 const skillBackground = useThemeColor("--color-inline-skill-background");
117 const skillBorder = useThemeColor("--color-inline-skill-border");
118 const skillText = useThemeColor("--color-inline-skill-foreground");
119 const fileTint = useThemeColor("--color-icon-muted");
120
121 useImperativeHandle(
122 ref,
123 () => ({
124 focus: () => void nativeRef.current?.focus(),
125 blur: () => void nativeRef.current?.blur(),
126 setSelection: (nextSelection) =>
127 void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end),
128 }),
129 [],
130 );
131
132 const skillLabels = useMemo(
133 () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])),
134 [skills],
135 );
136 const tokensJson = useMemo(() => {
137 const tokens = collectComposerInlineTokens(props.value, {
138 preserveTrailingFrom: confirmedTokensRef.current,
139 });
140 confirmedTokensRef.current = tokens;
141 return JSON.stringify(
142 tokens.map((token) => ({
143 type: token.type,
144 source: token.source,
145 start: token.start,
146 end: token.end,
147 label:
148 token.type === "skill"
149 ? (skillLabels.get(token.value) ?? token.value)
150 : basename(token.value),
151 iconUri: token.type === "mention" ? fileIconUri(token.value) : null,
152 })),
153 );
154 }, [props.value, skillLabels]);
155 const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current;
156 const controlledEventCount = includesNativeEvent
157 ? resolveComposerControlledEventCount(
158 props.value,
159 selection ?? null,
160 mostRecentEventCount,
161 nativeEventSnapshotsRef.current,
162 )
163 : mostRecentEventCount;
164 const acknowledgesLatestNativeEvent = isComposerNativeEcho(
165 props.value,
166 selection ?? null,
167 mostRecentEventCount,
168 nativeEventSnapshotsRef.current,
169 );
170 const isNativeEcho =
171 includesNativeEvent &&
172 controlledEventCount === mostRecentEventCount &&
173 acknowledgesLatestNativeEvent;
174 const controlledDocumentJson = JSON.stringify({
175 value: props.value,
176 selection: isNativeEcho ? null : (selection ?? null),
177 tokensJson,
178 mostRecentEventCount: controlledEventCount,
179 isNativeEcho,
180 });
181 useEffect(() => {
182 previousRenderedEventSequenceRef.current = nativeEventSequence;
183 }, [nativeEventSequence]);
184 useEffect(() => {
185 if (!acknowledgesLatestNativeEvent) return;
186 nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(
187 nativeEventSnapshotsRef.current,
188 mostRecentEventCount,
189 );
190 }, [acknowledgesLatestNativeEvent, mostRecentEventCount]);
191 const acceptNativeEvent = useCallback(
192 (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => {
193 const acknowledgedEventCount = acknowledgeComposerNativeEvent(
194 mostRecentEventCountRef.current,
195 eventCount,
196 );
197 if (acknowledgedEventCount === null) {
198 return false;
199 }
200 mostRecentEventCountRef.current = acknowledgedEventCount;
201 nativeEventSnapshotsRef.current.push({
202 eventCount: acknowledgedEventCount,
203 value,
204 selection: nextSelection,
205 });
206 return acknowledgedEventCount;
207 },
208 [],
209 );
210 const themeJson = JSON.stringify({
211 text: String(textColor),
212 placeholder: String(placeholderColor),
213 chipBackground: String(chipBackground),
214 chipBorder: String(chipBorder),
215 chipText: String(chipText),
216 skillBackground: String(skillBackground),
217 skillBorder: String(skillBorder),
218 skillText: String(skillText),
219 fileTint: String(fileTint),
220 });
221 const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {};
222 const regularFontFamily = useFontFamily("regular");
223 return (
224 <NativeView
225 ref={nativeRef}
226 controlledDocumentJson={controlledDocumentJson}
227 themeJson={themeJson}
228 placeholder={props.placeholder ?? ""}
229 fontFamily={
230 typeof resolvedTextStyle.fontFamily === "string"
231 ? resolvedTextStyle.fontFamily
232 : regularFontFamily
233 }
234 fontSize={
235 typeof resolvedTextStyle.fontSize === "number"
236 ? resolvedTextStyle.fontSize
237 : MOBILE_TYPOGRAPHY.body.fontSize
238 }
239 lineHeight={
240 typeof resolvedTextStyle.lineHeight === "number"
241 ? resolvedTextStyle.lineHeight
242 : MOBILE_TYPOGRAPHY.body.lineHeight
243 }
244 contentInsetVertical={contentInsetVertical}
245 singleLineCentered={props.singleLineCentered ?? false}
246 editable={props.editable ?? true}
247 scrollEnabled={props.scrollEnabled ?? true}
248 autoFocus={props.autoFocus ?? false}
249 autoCorrect={props.autoCorrect ?? true}
250 spellCheck={props.spellCheck ?? true}
251 style={style as StyleProp<ViewStyle>}
252 onComposerChange={(event) => {
253 const acknowledgedEventCount = acceptNativeEvent(
254 event.nativeEvent.eventCount,
255 event.nativeEvent.value,
256 event.nativeEvent.selection,
257 );
258 if (acknowledgedEventCount === false) return;
259 onChangeText(event.nativeEvent.value);
260 onSelectionChange?.(event.nativeEvent.selection);
261 setMostRecentEventCount(acknowledgedEventCount);
262 setNativeEventSequence((sequence) => sequence + 1);
263 }}
264 onComposerSelectionChange={(event) => {
265 const acknowledgedEventCount = acceptNativeEvent(
266 event.nativeEvent.eventCount,
267 event.nativeEvent.value,
268 event.nativeEvent.selection,
269 );
270 if (acknowledgedEventCount === false) return;
271 onSelectionChange?.(event.nativeEvent.selection);
272 setMostRecentEventCount(acknowledgedEventCount);
273 setNativeEventSequence((sequence) => sequence + 1);
274 }}
275 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
276 onComposerFocus={onFocus}
277 onComposerBlur={onBlur}
278 />
279 );
280}
281
282export type {
283 ComposerEditorHandle,
284 ComposerEditorProps,
285 ComposerEditorSelection,
286} from "./T3ComposerEditor.types";
apps/mobile/src/native/T3ComposerEditor.tsx+1
...@@ -16,6 +16,7 @@ export function ComposerEditor({...@@ -16,6 +16,7 @@ export function ComposerEditor({
16 style,16 style,
17 textStyle,17 textStyle,
18 contentInsetVertical = 0,18 contentInsetVertical = 0,
19 singleLineCentered: _singleLineCentered,
19 ...props20 ...props
20}: ComposerEditorProps) {21}: ComposerEditorProps) {
21 const inputRef = useRef<RNTextInput>(null);22 const inputRef = useRef<RNTextInput>(null);
apps/mobile/src/native/T3ComposerEditor.types.ts+2
...@@ -28,6 +28,8 @@ export interface ComposerEditorProps {...@@ -28,6 +28,8 @@ export interface ComposerEditorProps {
28 readonly spellCheck?: boolean;28 readonly spellCheck?: boolean;
29 readonly multiline?: boolean;29 readonly multiline?: boolean;
30 readonly contentInsetVertical?: number;30 readonly contentInsetVertical?: number;
31 /** Android: center a single line vertically (collapsed pill); no-op on iOS. */
32 readonly singleLineCentered?: boolean;
31 readonly style?: StyleProp<ViewStyle>;33 readonly style?: StyleProp<ViewStyle>;
32 readonly textStyle?: StyleProp<TextStyle>;34 readonly textStyle?: StyleProp<TextStyle>;
33 readonly onChangeText: (value: string) => void;35 readonly onChangeText: (value: string) => void;
apps/mobile/src/native/T3HeaderButton.android.tsx created+26
...@@ -0,0 +1,26 @@
1import { requireNativeView } from "expo";
2import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native";
3
4interface NativeHeaderButtonProps extends ViewProps {
5 readonly label: string;
6 readonly systemImage: "gearshape" | "square.and.pencil";
7 readonly onTriggered: (event: NativeSyntheticEvent<Record<string, never>>) => void;
8}
9
10const NativeHeaderButton = requireNativeView<NativeHeaderButtonProps>("T3NativeControls");
11
12export function T3HeaderButton(props: {
13 readonly accessibilityLabel: string;
14 readonly icon: NativeHeaderButtonProps["systemImage"];
15 readonly onPress: () => void;
16 readonly style?: StyleProp<ViewStyle>;
17}) {
18 return (
19 <NativeHeaderButton
20 label={props.accessibilityLabel}
21 onTriggered={props.onPress}
22 style={[{ width: 44, height: 44 }, props.style]}
23 systemImage={props.icon}
24 />
25 );
26}
apps/mobile/src/persistence/imperative.ts+7-1
...@@ -5,7 +5,7 @@ import * as MobilePreferences from "./mobile-preferences";...@@ -5,7 +5,7 @@ import * as MobilePreferences from "./mobile-preferences";
5import * as MobileStorage from "./mobile-storage";5import * as MobileStorage from "./mobile-storage";
66
7export type { Preferences } from "./mobile-preferences";7export type { Preferences } from "./mobile-preferences";
8export type { AgentAwarenessRegistrationRecord } from "./mobile-storage";8export type { AgentAwarenessRegistrationRecord, RecentThreadShortcut } from "./mobile-storage";
9export { MobilePreferencesLoadError, MobilePreferencesSaveError } from "./mobile-preferences";9export { MobilePreferencesLoadError, MobilePreferencesSaveError } from "./mobile-preferences";
10export {10export {
11 MobileDeviceIdGenerationError,11 MobileDeviceIdGenerationError,
...@@ -45,3 +45,9 @@ export const saveAgentAwarenessRegistrationRecord = (...@@ -45,3 +45,9 @@ export const saveAgentAwarenessRegistrationRecord = (
45) => runStorage((storage) => storage.saveAgentAwarenessRegistrationRecord(record));45) => runStorage((storage) => storage.saveAgentAwarenessRegistrationRecord(record));
46export const clearAgentAwarenessRegistrationRecord = () =>46export const clearAgentAwarenessRegistrationRecord = () =>
47 runStorage((storage) => storage.clearAgentAwarenessRegistrationRecord);47 runStorage((storage) => storage.clearAgentAwarenessRegistrationRecord);
48
49export const loadRecentThreadShortcuts = () =>
50 runStorage((storage) => storage.loadRecentThreadShortcuts);
51export const saveRecentThreadShortcuts = (
52 threads: ReadonlyArray<MobileStorage.RecentThreadShortcut>,
53) => runStorage((storage) => storage.saveRecentThreadShortcuts(threads));
apps/mobile/src/persistence/mobile-storage.ts+39
...@@ -16,6 +16,7 @@ import * as MobileSecureStorage from "./mobile-secure-storage";...@@ -16,6 +16,7 @@ import * as MobileSecureStorage from "./mobile-secure-storage";
16const CONNECTIONS_KEY = "t3code.connections";16const CONNECTIONS_KEY = "t3code.connections";
17const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";17const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
18const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";18const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";
19const RECENT_THREAD_SHORTCUTS_KEY = "t3code.recent-thread-shortcuts";
1920
20export class MobileStorageDecodeError extends Schema.TaggedErrorClass<MobileStorageDecodeError>()(21export class MobileStorageDecodeError extends Schema.TaggedErrorClass<MobileStorageDecodeError>()(
21 "MobileStorageDecodeError",22 "MobileStorageDecodeError",
...@@ -56,6 +57,12 @@ export interface AgentAwarenessRegistrationRecord {...@@ -56,6 +57,12 @@ export interface AgentAwarenessRegistrationRecord {
56 readonly pushToStartToken?: string;57 readonly pushToStartToken?: string;
57}58}
5859
60export interface RecentThreadShortcut {
61 readonly environmentId: string;
62 readonly threadId: string;
63 readonly title: string;
64}
65
59export class MobileStorage extends Context.Service<66export class MobileStorage extends Context.Service<
60 MobileStorage,67 MobileStorage,
61 {68 {
...@@ -97,6 +104,16 @@ export class MobileStorage extends Context.Service<...@@ -97,6 +104,16 @@ export class MobileStorage extends Context.Service<
97 void,104 void,
98 MobileSecureStorage.MobileSecureStorageError105 MobileSecureStorage.MobileSecureStorageError
99 >;106 >;
107 readonly loadRecentThreadShortcuts: Effect.Effect<
108 ReadonlyArray<RecentThreadShortcut>,
109 MobileSecureStorage.MobileSecureStorageError
110 >;
111 readonly saveRecentThreadShortcuts: (
112 threads: ReadonlyArray<RecentThreadShortcut>,
113 ) => Effect.Effect<
114 void,
115 MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
116 >;
100 }117 }
101>()("@t3tools/mobile/persistence/MobileStorage") {}118>()("@t3tools/mobile/persistence/MobileStorage") {}
102119
...@@ -208,6 +225,26 @@ export const make = Effect.fn("MobileStorage.make")(function* () {...@@ -208,6 +225,26 @@ export const make = Effect.fn("MobileStorage.make")(function* () {
208 }),225 }),
209 );226 );
210227
228 // Threads most recently opened on this device, newest first — the source
229 // for the launcher's dynamic "recent thread" app shortcuts.
230 const loadRecentThreadShortcuts = readJson<{
231 readonly threads?: ReadonlyArray<RecentThreadShortcut>;
232 }>(RECENT_THREAD_SHORTCUTS_KEY).pipe(
233 Effect.map((parsed) =>
234 pipe(
235 parsed?.threads ?? [],
236 Arr.filter(
237 (thread) =>
238 typeof thread?.environmentId === "string" &&
239 thread.environmentId.length > 0 &&
240 typeof thread.threadId === "string" &&
241 thread.threadId.length > 0 &&
242 typeof thread.title === "string",
243 ),
244 ),
245 ),
246 );
247
211 return MobileStorage.of({248 return MobileStorage.of({
212 loadSavedConnections,249 loadSavedConnections,
213 saveConnection,250 saveConnection,
...@@ -221,6 +258,8 @@ export const make = Effect.fn("MobileStorage.make")(function* () {...@@ -221,6 +258,8 @@ export const make = Effect.fn("MobileStorage.make")(function* () {
221 AGENT_AWARENESS_REGISTRATION_KEY,258 AGENT_AWARENESS_REGISTRATION_KEY,
222 "",259 "",
223 ),260 ),
261 loadRecentThreadShortcuts,
262 saveRecentThreadShortcuts: (threads) => writeJson(RECENT_THREAD_SHORTCUTS_KEY, { threads }),
224 });263 });
225});264});
226265
pnpm-lock.yaml+114
...@@ -258,6 +258,9 @@ importers:...@@ -258,6 +258,9 @@ importers:
258 '@t3tools/shared':258 '@t3tools/shared':
259 specifier: workspace:*259 specifier: workspace:*
260 version: link:../../packages/shared260 version: link:../../packages/shared
261 '@tabler/icons-react-native':
262 specifier: ^3.44.0
263 version: 3.44.0(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react@19.2.3)
261 clsx:264 clsx:
262 specifier: ^2.1.1265 specifier: ^2.1.1
263 version: 2.1.1266 version: 2.1.1
...@@ -276,6 +279,9 @@ importers:...@@ -276,6 +279,9 @@ importers:
276 expo-auth-session:279 expo-auth-session:
277 specifier: ~56.0.14280 specifier: ~56.0.14
278 version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)281 version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
282 expo-blur:
283 specifier: ~56.0.3
284 version: 56.0.3(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
279 expo-build-properties:285 expo-build-properties:
280 specifier: ~56.0.19286 specifier: ~56.0.19
281 version: 56.0.19(expo@56.0.12)287 version: 56.0.19(expo@56.0.12)
...@@ -324,6 +330,9 @@ importers:...@@ -324,6 +330,9 @@ importers:
324 expo-paste-input:330 expo-paste-input:
325 specifier: ^0.1.15331 specifier: ^0.1.15
326 version: 0.1.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)332 version: 0.1.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
333 expo-quick-actions:
334 specifier: ^6.0.2
335 version: 6.0.2(expo@56.0.12)(typescript@6.0.3)
327 expo-secure-store:336 expo-secure-store:
328 specifier: ~56.0.4337 specifier: ~56.0.4
329 version: 56.0.4(expo@56.0.12)338 version: 56.0.4(expo@56.0.12)
...@@ -2463,6 +2472,9 @@ packages:...@@ -2463,6 +2472,9 @@ packages:
2463 '@expo/image-utils@0.10.1':2472 '@expo/image-utils@0.10.1':
2464 resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==}2473 resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==}
24652474
2475 '@expo/image-utils@0.8.14':
2476 resolution: {integrity: sha512-5Sn+jG4Cw+shC2wDMXoqSAJnvERbiwzHn05FpWtD5IBflfTIs5gUmjzwiGVyjOdlMSQhgRrw/AymPbmO9h9mpQ==}
2477
2466 '@expo/inline-modules@0.0.12':2478 '@expo/inline-modules@0.0.12':
2467 resolution: {integrity: sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==}2479 resolution: {integrity: sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==}
24682480
...@@ -2519,6 +2531,14 @@ packages:...@@ -2519,6 +2531,14 @@ packages:
2519 '@expo/prebuild-config@56.0.16':2531 '@expo/prebuild-config@56.0.16':
2520 resolution: {integrity: sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==}2532 resolution: {integrity: sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==}
25212533
2534 '@expo/require-utils@55.0.5':
2535 resolution: {integrity: sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==}
2536 peerDependencies:
2537 typescript: ^5.0.0 || ^5.0.0-0
2538 peerDependenciesMeta:
2539 typescript:
2540 optional: true
2541
2522 '@expo/require-utils@56.1.3':2542 '@expo/require-utils@56.1.3':
2523 resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==}2543 resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==}
2524 peerDependencies:2544 peerDependencies:
...@@ -4504,6 +4524,15 @@ packages:...@@ -4504,6 +4524,15 @@ packages:
4504 '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal':4524 '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal':
4505 resolution: {directory: apps/mobile/modules/t3-terminal, type: directory}4525 resolution: {directory: apps/mobile/modules/t3-terminal, type: directory}
45064526
4527 '@tabler/icons-react-native@3.44.0':
4528 resolution: {integrity: sha512-/Ppp68bpl9vQQsI/c+YiDC9dnLq4JUo0YI5qP72OQMC1WmCaRzdYn3CU79q01M2L3MfXO9a8WctWN8ay1Odkow==}
4529 peerDependencies:
4530 react: '>= 16.5.1'
4531 react-native-svg: '>= 13.0.0'
4532
4533 '@tabler/icons@3.44.0':
4534 resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==}
4535
4507 '@tailwindcss/node@4.2.1':4536 '@tailwindcss/node@4.2.1':
4508 resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}4537 resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
45094538
...@@ -4852,6 +4881,9 @@ packages:...@@ -4852,6 +4881,9 @@ packages:
4852 '@types/istanbul-reports@3.0.4':4881 '@types/istanbul-reports@3.0.4':
4853 resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}4882 resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
48544883
4884 '@types/json-schema@7.0.15':
4885 resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
4886
4855 '@types/keyv@3.1.4':4887 '@types/keyv@3.1.4':
4856 resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}4888 resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
48574889
...@@ -5289,6 +5321,11 @@ packages:...@@ -5289,6 +5321,11 @@ packages:
5289 ajv:5321 ajv:
5290 optional: true5322 optional: true
52915323
5324 ajv-keywords@5.1.0:
5325 resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==}
5326 peerDependencies:
5327 ajv: ^8.8.2
5328
5292 ajv@6.15.0:5329 ajv@6.15.0:
5293 resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}5330 resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
52945331
...@@ -6471,6 +6508,13 @@ packages:...@@ -6471,6 +6508,13 @@ packages:
6471 react: '*'6508 react: '*'
6472 react-native: '*'6509 react-native: '*'
64736510
6511 expo-blur@56.0.3:
6512 resolution: {integrity: sha512-KDDtrpWc2tYlm1WCPaOgBtv+YEGqe5ELheFPIgSNgHt28NQUDcfBcFsA9Us2StDh6osmSD6NbKxOt5bU6PcDbQ==}
6513 peerDependencies:
6514 expo: '*'
6515 react: '*'
6516 react-native: '*'
6517
6474 expo-build-properties@56.0.19:6518 expo-build-properties@56.0.19:
6475 resolution: {integrity: sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==}6519 resolution: {integrity: sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==}
6476 peerDependencies:6520 peerDependencies:
...@@ -6635,6 +6679,11 @@ packages:...@@ -6635,6 +6679,11 @@ packages:
6635 react: '*'6679 react: '*'
6636 react-native: '*'6680 react-native: '*'
66376681
6682 expo-quick-actions@6.0.2:
6683 resolution: {integrity: sha512-IVoTMCx55MjSnPqUEb5jhePpOAUdOTonifY3QXM/DBFxc6qMzBy5iJWEkfDZcFAzFzahK1ZW9tFaNv4pi2AAvQ==}
6684 peerDependencies:
6685 expo: '*'
6686
6638 expo-router@56.2.11:6687 expo-router@56.2.11:
6639 resolution: {integrity: sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ==}6688 resolution: {integrity: sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ==}
6640 peerDependencies:6689 peerDependencies:
...@@ -9193,6 +9242,10 @@ packages:...@@ -9193,6 +9242,10 @@ packages:
9193 scheduler@0.27.0:9242 scheduler@0.27.0:
9194 resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}9243 resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
91959244
9245 schema-utils@4.3.3:
9246 resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
9247 engines: {node: '>= 10.13.0'}
9248
9196 semver-compare@1.0.0:9249 semver-compare@1.0.0:
9197 resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}9250 resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
91989251
...@@ -12296,6 +12349,19 @@ snapshots:...@@ -12296,6 +12349,19 @@ snapshots:
12296 - supports-color12349 - supports-color
12297 - typescript12350 - typescript
1229812351
12352 '@expo/image-utils@0.8.14(typescript@6.0.3)':
12353 dependencies:
12354 '@expo/require-utils': 55.0.5(typescript@6.0.3)
12355 '@expo/spawn-async': 1.8.0
12356 chalk: 4.1.2
12357 getenv: 2.0.0
12358 jimp-compact: 0.16.1
12359 parse-png: 2.1.0
12360 semver: 7.8.5
12361 transitivePeerDependencies:
12362 - supports-color
12363 - typescript
12364
12299 '@expo/inline-modules@0.0.12(typescript@6.0.3)':12365 '@expo/inline-modules@0.0.12(typescript@6.0.3)':
12300 dependencies:12366 dependencies:
12301 '@expo/config-plugins': 56.0.9(typescript@6.0.3)12367 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
...@@ -12462,6 +12528,16 @@ snapshots:...@@ -12462,6 +12528,16 @@ snapshots:
12462 - supports-color12528 - supports-color
12463 - typescript12529 - typescript
1246412530
12531 '@expo/require-utils@55.0.5(typescript@6.0.3)':
12532 dependencies:
12533 '@babel/code-frame': 7.29.7
12534 '@babel/core': 7.29.7
12535 '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7)
12536 optionalDependencies:
12537 typescript: 6.0.3
12538 transitivePeerDependencies:
12539 - supports-color
12540
12465 '@expo/require-utils@56.1.3(typescript@6.0.3)':12541 '@expo/require-utils@56.1.3(typescript@6.0.3)':
12466 dependencies:12542 dependencies:
12467 '@babel/code-frame': 7.29.712543 '@babel/code-frame': 7.29.7
...@@ -14498,6 +14574,14 @@ snapshots:...@@ -14498,6 +14574,14 @@ snapshots:
1449814574
14499 '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {}14575 '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {}
1450014576
14577 '@tabler/icons-react-native@3.44.0(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react@19.2.3)':
14578 dependencies:
14579 '@tabler/icons': 3.44.0
14580 react: 19.2.3
14581 react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
14582
14583 '@tabler/icons@3.44.0': {}
14584
14501 '@tailwindcss/node@4.2.1':14585 '@tailwindcss/node@4.2.1':
14502 dependencies:14586 dependencies:
14503 '@jridgewell/remapping': 2.3.514587 '@jridgewell/remapping': 2.3.5
...@@ -14836,6 +14920,8 @@ snapshots:...@@ -14836,6 +14920,8 @@ snapshots:
14836 dependencies:14920 dependencies:
14837 '@types/istanbul-lib-report': 3.0.314921 '@types/istanbul-lib-report': 3.0.3
1483814922
14923 '@types/json-schema@7.0.15': {}
14924
14839 '@types/keyv@3.1.4':14925 '@types/keyv@3.1.4':
14840 dependencies:14926 dependencies:
14841 '@types/node': 24.12.414927 '@types/node': 24.12.4
...@@ -15194,6 +15280,11 @@ snapshots:...@@ -15194,6 +15280,11 @@ snapshots:
15194 optionalDependencies:15280 optionalDependencies:
15195 ajv: 8.20.015281 ajv: 8.20.0
1519615282
15283 ajv-keywords@5.1.0(ajv@8.20.0):
15284 dependencies:
15285 ajv: 8.20.0
15286 fast-deep-equal: 3.1.3
15287
15197 ajv@6.15.0:15288 ajv@6.15.0:
15198 dependencies:15289 dependencies:
15199 fast-deep-equal: 3.1.315290 fast-deep-equal: 3.1.3
...@@ -16583,6 +16674,12 @@ snapshots:...@@ -16583,6 +16674,12 @@ snapshots:
16583 - expo16674 - expo
16584 - supports-color16675 - supports-color
1658516676
16677 expo-blur@56.0.3(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
16678 dependencies:
16679 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
16680 react: 19.2.3
16681 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
16682
16586 expo-build-properties@56.0.19(expo@56.0.12):16683 expo-build-properties@56.0.19(expo@56.0.12):
16587 dependencies:16684 dependencies:
16588 '@expo/schema-utils': 56.0.116685 '@expo/schema-utils': 56.0.1
...@@ -16820,6 +16917,16 @@ snapshots:...@@ -16820,6 +16917,16 @@ snapshots:
16820 react: 19.2.316917 react: 19.2.3
16821 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)16918 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1682216919
16920 expo-quick-actions@6.0.2(expo@56.0.12)(typescript@6.0.3):
16921 dependencies:
16922 '@expo/image-utils': 0.8.14(typescript@6.0.3)
16923 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
16924 schema-utils: 4.3.3
16925 sf-symbols-typescript: 2.2.0
16926 transitivePeerDependencies:
16927 - supports-color
16928 - typescript
16929
16823 expo-router@56.2.11(014c98b83770a9a763d36edd2815d6d7):16930 expo-router@56.2.11(014c98b83770a9a763d36edd2815d6d7):
16824 dependencies:16931 dependencies:
16825 '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)16932 '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
...@@ -20265,6 +20372,13 @@ snapshots:...@@ -20265,6 +20372,13 @@ snapshots:
2026520372
20266 scheduler@0.27.0: {}20373 scheduler@0.27.0: {}
2026720374
20375 schema-utils@4.3.3:
20376 dependencies:
20377 '@types/json-schema': 7.0.15
20378 ajv: 8.20.0
20379 ajv-formats: 2.1.1(ajv@8.20.0)
20380 ajv-keywords: 5.1.0(ajv@8.20.0)
20381
20268 semver-compare@1.0.0:20382 semver-compare@1.0.0:
20269 optional: true20383 optional: true
2027020384