Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ internal object T3ComposerClipboard {
}

class T3ComposerEditorModule : Module() {
// A flat list of the view's props and events.
@Suppress("LongMethod")
override fun definition() = ModuleDefinition {
Name("T3ComposerEditor")

Expand Down Expand Up @@ -104,6 +106,12 @@ class T3ComposerEditorModule : Module() {
Prop("readOnly") { view: T3ComposerEditorView, readOnly: Boolean ->
view.setReadOnly(readOnly)
}
Prop("enterBehavior") { view: T3ComposerEditorView, behavior: String ->
view.setEnterBehavior(behavior)
}
Prop("submitEnabled") { view: T3ComposerEditorView, enabled: Boolean ->
view.setSubmitEnabled(enabled)
}
Prop("scrollEnabled") { view: T3ComposerEditorView, scrollEnabled: Boolean ->
view.setScrollEnabled(scrollEnabled)
}
Expand All @@ -128,6 +136,7 @@ class T3ComposerEditorModule : Module() {
"onComposerSelectionChange",
"onComposerFocus",
"onComposerBlur",
"onComposerSubmit",
"onComposerPasteImages",
"onComposerContextPress",
"onComposerPasteContext",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import android.text.style.ReplacementSpan
import android.util.TypedValue
import android.view.Gravity
import android.view.GestureDetector
import android.view.KeyCharacterMap
import android.view.MotionEvent
import android.view.KeyEvent
import android.view.ViewGroup
Expand Down Expand Up @@ -47,6 +48,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
private val onComposerSelectionChange by EventDispatcher()
private val onComposerFocus by EventDispatcher()
private val onComposerBlur by EventDispatcher()
private val onComposerSubmit by EventDispatcher()
private val onComposerPasteImages by EventDispatcher()
private val onComposerContextPress by EventDispatcher()
private val onComposerPasteContext by EventDispatcher()
Expand Down Expand Up @@ -143,6 +145,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
),
)
}
editor.submitListener = { alternate ->
onComposerSubmit(mapOf("alternate" to alternate))
}
editor.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
onComposerFocus(emptyMap<String, Any>())
Expand Down Expand Up @@ -310,6 +315,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
editor.isCursorVisible = editor.isEnabled && !readOnly
}

fun setEnterBehavior(behavior: String) {
editor.returnSends = behavior != "newline"
}

fun setSubmitEnabled(enabled: Boolean) {
editor.submitEnabled = enabled
}

fun setScrollEnabled(scrollEnabled: Boolean) {
editor.isVerticalScrollBarEnabled = scrollEnabled
}
Expand Down Expand Up @@ -586,10 +599,19 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) {
var pasteImagesListener: ((List<String>) -> Unit)? = null
var pasteContextListener: ((Map<String, String>) -> Unit)? = null
var pasteTextListener: ((String, Int, Int) -> Unit)? = null

/** Called for a hardware-keyboard send chord. The argument is true for the alternate send. */
var submitListener: ((Boolean) -> Unit)? = null
var textPasteThresholdBytes = 0
var maxInputChars = Int.MAX_VALUE
var clipboardFragment = ""

/** Whether a hardware Return sends (`enterBehavior` "send") or inserts a newline. */
var returnSends = true

/** The JS wrapper enables submission only when it has an onSubmit handler. */
var submitEnabled = false

/**
* Placeholder shown while the draft is empty. An editable TextView never ellipsizes its hint,
* so a long placeholder wraps once a wide system font or a large font scale (Samsung defaults)
Expand Down Expand Up @@ -642,7 +664,38 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) {
return spans.isNotEmpty()
}

/**
* Maps a hardware Return chord to a send, like the iOS composer. The plainer chord does the
* configured behavior and the Ctrl chord does the other one: with "send", Return sends,
* Ctrl-Return sends the alternate way and Shift-Return inserts a newline; with "newline",
* Ctrl-Return sends and Ctrl-Shift-Return sends the alternate way. Returns whether the send is
* the alternate one, or null to insert a newline. Soft keyboards always insert a newline.
*/
private fun submitChord(keyCode: Int, event: KeyEvent): Boolean? {
val isReturn = keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER
val fromSoftKeyboard =
event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD ||
(event.flags and KeyEvent.FLAG_SOFT_KEYBOARD) != 0
val canSubmit = submitEnabled && submitListener != null && !readOnly
if (!isReturn || fromSoftKeyboard || !canSubmit) {
return null
}
return when {
event.hasNoModifiers() -> if (returnSends) false else null
event.hasModifiers(KeyEvent.META_CTRL_ON) -> returnSends
event.hasModifiers(KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON) ->
if (returnSends) null else true
else -> null
}
}

override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
val alternate = submitChord(keyCode, event)
if (alternate != null) {
// A held Return repeats. Send once, and drop the repeats instead of typing newlines.
if (event.repeatCount == 0) submitListener?.invoke(alternate)
return true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
val handled = when (keyCode) {
KeyEvent.KEYCODE_DEL -> deleteChip(true)
KeyEvent.KEYCODE_FORWARD_DEL -> deleteChip(false)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package expo.modules.t3composereditor

import android.text.InputType
import android.view.KeyCharacterMap
import android.view.KeyEvent
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36], manifest = Config.NONE)
class ComposerSubmitTest {
private val sends = mutableListOf<Boolean>()
private val editor = SelectionAwareEditText(RuntimeEnvironment.getApplication()).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
submitEnabled = true
submitListener = { alternate -> sends += alternate }
}

private fun pressReturn(
metaState: Int = 0,
deviceId: Int = HARDWARE_KEYBOARD,
repeatCount: Int = 0
) {
val event = KeyEvent(
0,
0,
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_ENTER,
repeatCount,
metaState,
deviceId,
0,
)
editor.onKeyDown(KeyEvent.KEYCODE_ENTER, event)
}

@Test
fun returnSendsAndCtrlReturnSendsTheAlternateWay() {
pressReturn()
pressReturn(KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON)

assertEquals(listOf(false, true), sends)
assertEquals("", editor.text.toString())
}

@Test
fun shiftReturnAndSoftKeyboardReturnInsertNewlines() {
pressReturn(KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON)
pressReturn(deviceId = KeyCharacterMap.VIRTUAL_KEYBOARD)

assertEquals(emptyList<Boolean>(), sends)
assertEquals("\n\n", editor.text.toString())
}

@Test
fun newlineBehaviorSendsOnlyWithCtrl() {
editor.returnSends = false

pressReturn()
pressReturn(KeyEvent.META_CTRL_ON)
pressReturn(KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON)

assertEquals(listOf(false, true), sends)
assertEquals("\n", editor.text.toString())
}

@Test
fun returnInsertsANewlineWhenSubmissionIsUnavailable() {
editor.submitEnabled = false

pressReturn()

assertEquals(emptyList<Boolean>(), sends)
assertEquals("\n", editor.text.toString())
}

@Test
fun returnInsertsANewlineWithoutASubmitListener() {
editor.submitListener = null

pressReturn()

assertEquals(emptyList<Boolean>(), sends)
assertEquals("\n", editor.text.toString())
}

@Test
fun heldReturnSendsOnce() {
pressReturn()
pressReturn(repeatCount = 1)
pressReturn(repeatCount = 2)

assertEquals(listOf(false), sends)
assertEquals("", editor.text.toString())
}

private companion object {
const val HARDWARE_KEYBOARD = 1
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package expo.modules.t3nativecontrols

import android.content.Context
import android.os.Build
import android.view.KeyEvent
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView

// Android's tablet breakpoint. Large screens get the iPad-only commands.
private const val LARGE_SCREEN_MIN_WIDTH_DP = 600

class T3KeyboardCommandsModule : Module() {
override fun definition() = ModuleDefinition {
Name("T3KeyboardCommands")
Expand All @@ -28,19 +32,72 @@ class T3KeyboardCommandsView(
private val onCommand by EventDispatcher()
var enabledCommands = emptySet<String>()

init {
// A key reaches dispatchKeyEvent only while focus is inside this view. With nothing focused,
// Android offers it to unhandled-key listeners instead. That listener needs API 28, so on
// API 24-27 shortcuts work only while something inside this view has focus.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
addOnUnhandledKeyEventListener { _, event ->
val command = enabledCommandFor(event)
if (command != null) onCommand(mapOf("command" to command))
command != null
}
}
}

override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val copiesThreadReference =
event.action == KeyEvent.ACTION_DOWN &&
event.repeatCount == 0 &&
event.keyCode == KeyEvent.KEYCODE_C &&
event.isCtrlPressed &&
event.isShiftPressed &&
!event.isAltPressed &&
enabledCommands.contains("copyThreadReference")
if (copiesThreadReference) {
onCommand(mapOf("command" to "copyThreadReference"))
return true
val command = enabledCommandFor(event) ?: return super.dispatchKeyEvent(event)
// Unmodified keys (palette arrows and Escape) win over the focused search field. Ctrl chords
// go to the focused view first, so the terminal keeps the control keys it sends to the shell.
val focusedViewHandled = !event.hasNoModifiers() && super.dispatchKeyEvent(event)
if (!focusedViewHandled) onCommand(mapOf("command" to command))
return true
}

private fun enabledCommandFor(event: KeyEvent): String? {
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0) return null
val largeScreen = resources.configuration.smallestScreenWidthDp >= LARGE_SCREEN_MIN_WIDTH_DP
return hardwareKeyboardCommand(event, largeScreen)?.takeIf(enabledCommands::contains)
}
}

private val UNMODIFIED_COMMANDS = mapOf(
KeyEvent.KEYCODE_DPAD_DOWN to "paletteNext",
KeyEvent.KEYCODE_DPAD_UP to "palettePrevious",
KeyEvent.KEYCODE_ESCAPE to "paletteDismiss",
)

private val CTRL_COMMANDS = mapOf(
KeyEvent.KEYCODE_N to "newTask",
KeyEvent.KEYCODE_F to "focusSearch",
KeyEvent.KEYCODE_LEFT_BRACKET to "back",
KeyEvent.KEYCODE_BACKSLASH to "toggleSidebar",
)

private val CTRL_SHIFT_COMMANDS = mapOf(
KeyEvent.KEYCODE_F to "files",
KeyEvent.KEYCODE_T to "terminal",
KeyEvent.KEYCODE_R to "review",
KeyEvent.KEYCODE_C to "copyThreadReference",
)

/**
* The command for a hardware-keyboard chord. Matches the iOS module with Ctrl in place of
* Command. Like iPad, large screens get the command palette and thread jumps; on phones Ctrl-K
* focuses search.
*/
private fun hardwareKeyboardCommand(event: KeyEvent, largeScreen: Boolean): String? {
val keyCode = event.keyCode
return when {
event.hasNoModifiers() -> UNMODIFIED_COMMANDS[keyCode]
event.hasModifiers(KeyEvent.META_CTRL_ON) -> when {
keyCode == KeyEvent.KEYCODE_K -> if (largeScreen) "commandPalette" else "focusSearch"
largeScreen && keyCode in KeyEvent.KEYCODE_1..KeyEvent.KEYCODE_9 ->
"thread.jump.${keyCode - KeyEvent.KEYCODE_0}"
else -> CTRL_COMMANDS[keyCode]
}
return super.dispatchKeyEvent(event)
event.hasModifiers(KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON) ->
CTRL_SHIFT_COMMANDS[keyCode]
else -> null
}
}
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
},
"autolinking": {
"buildFromSource": [
"expo-modules-core",
"expo-notifications",
"react-native-screens",
"react-native-reanimated",
Expand Down
Loading
Loading