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 @@ -39,6 +39,7 @@ import com.darkrockstudios.texteditor.contextmenu.TextEditorContextMenuState
import com.darkrockstudios.texteditor.cursor.DrawCursor
import com.darkrockstudios.texteditor.input.CaptureViewForIme
import com.darkrockstudios.texteditor.input.TextEditorInputModifierElement
import com.darkrockstudios.texteditor.input.TextFormattingShortcuts
import com.darkrockstudios.texteditor.richstyle.BlockSpanStyle
import com.darkrockstudios.texteditor.richstyle.RichSpan
import com.darkrockstudios.texteditor.scrollbar.TextEditorScrollbar
Expand Down Expand Up @@ -69,6 +70,10 @@ private const val CURSOR_BLINK_SPEED_MS = 500L
* return `true` to consume the event.
* @param decorateLine Optional per-line decorator drawn behind each line, keyed by
* line index — useful for gutters, current-line highlights, or diff markers.
* @param formattingShortcuts Keyboard shortcuts that toggle inline formatting
* (Ctrl+B bold, Ctrl+I italic, Ctrl+Shift+X strikethrough, Ctrl+E code). Pass
* [TextFormattingShortcuts.None] to disable, or supply styles matching a custom
* markdown configuration.
*/
@Composable
fun BasicTextEditor(
Expand All @@ -82,6 +87,7 @@ fun BasicTextEditor(
contextMenuState: TextEditorContextMenuState? = null,
onRichSpanClick: RichSpanClickListener? = null,
decorateLine: LineDecorator? = null,
formattingShortcuts: TextFormattingShortcuts = TextFormattingShortcuts.Default,
) {
// Capture platform view for IME cursor synchronization (Android only)
CaptureViewForIme(state)
Expand All @@ -92,8 +98,8 @@ fun BasicTextEditor(
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current

val inputModifierElement = remember(state, clipboard, enabled) {
TextEditorInputModifierElement(state, clipboard, enabled)
val inputModifierElement = remember(state, clipboard, enabled, formattingShortcuts) {
TextEditorInputModifierElement(state, clipboard, enabled, formattingShortcuts)
}

val horizontalPadding = remember(contentPadding, layoutDirection) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.darkrockstudios.texteditor.input.TextFormattingShortcuts
import com.darkrockstudios.texteditor.state.TextEditorState
import com.darkrockstudios.texteditor.state.rememberTextEditorState

Expand All @@ -27,6 +28,10 @@ private val DefaultContentPadding = PaddingValues(16.dp)
* @param style Colors and text style for the editor and its gutter markers.
* @param onRichSpanClick Invoked when a rich span (link, list, blockquote, code
* block, …) is tapped or right-clicked; return `true` to consume the event.
* @param formattingShortcuts Keyboard shortcuts that toggle inline formatting
* (Ctrl+B bold, Ctrl+I italic, Ctrl+Shift+X strikethrough, Ctrl+E code). Pass
* [TextFormattingShortcuts.None] to disable, or supply styles matching a custom
* markdown configuration.
*/
@Composable
fun TextEditor(
Expand All @@ -37,6 +42,7 @@ fun TextEditor(
autoFocus: Boolean = false,
style: TextEditorStyle = rememberTextEditorStyle(),
onRichSpanClick: RichSpanClickListener? = null,
formattingShortcuts: TextFormattingShortcuts = TextFormattingShortcuts.Default,
) {
Surface(modifier = modifier.focusBorder(state.isFocused && enabled, style)) {
BasicTextEditor(
Expand All @@ -47,6 +53,7 @@ fun TextEditor(
autoFocus = autoFocus,
style = style,
onRichSpanClick = onRichSpanClick,
formattingShortcuts = formattingShortcuts,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import kotlinx.coroutines.launch
internal class TextEditorInputModifierNode(
var state: TextEditorState,
var clipboard: Clipboard,
var enabled: Boolean
var enabled: Boolean,
var formattingShortcuts: TextFormattingShortcuts
) : androidx.compose.ui.Modifier.Node(),
KeyInputModifierNode,
SoftKeyboardInterceptionModifierNode,
Expand Down Expand Up @@ -86,7 +87,7 @@ internal class TextEditorInputModifierNode(
}

override fun onPreKeyEvent(event: KeyEvent): Boolean {
return keyCommandHandler.handleKeyEvent(event, state, clipboard, coroutineScope, enabled)
return keyCommandHandler.handleKeyEvent(event, state, clipboard, coroutineScope, enabled, formattingShortcuts)
}

override fun onKeyEvent(event: KeyEvent): Boolean {
Expand All @@ -103,19 +104,21 @@ internal class TextEditorInputModifierNode(
// commitText through InputConnection. The pre-intercept (top-down) phase is
// where we want to win — bottom-up just returns false.
override fun onPreInterceptKeyBeforeSoftKeyboard(event: KeyEvent): Boolean {
return keyCommandHandler.handleKeyEvent(event, state, clipboard, coroutineScope, enabled)
return keyCommandHandler.handleKeyEvent(event, state, clipboard, coroutineScope, enabled, formattingShortcuts)
}

override fun onInterceptKeyBeforeSoftKeyboard(event: KeyEvent): Boolean = false

fun update(
state: TextEditorState,
clipboard: Clipboard,
enabled: Boolean
enabled: Boolean,
formattingShortcuts: TextFormattingShortcuts
) {
this.state = state
this.clipboard = clipboard
this.enabled = enabled
this.formattingShortcuts = formattingShortcuts
}
}

Expand All @@ -125,15 +128,16 @@ internal class TextEditorInputModifierNode(
internal data class TextEditorInputModifierElement(
val state: TextEditorState,
val clipboard: Clipboard,
val enabled: Boolean
val enabled: Boolean,
val formattingShortcuts: TextFormattingShortcuts = TextFormattingShortcuts.Default
) : ModifierNodeElement<TextEditorInputModifierNode>() {

override fun create(): TextEditorInputModifierNode {
return TextEditorInputModifierNode(state, clipboard, enabled)
return TextEditorInputModifierNode(state, clipboard, enabled, formattingShortcuts)
}

override fun update(node: TextEditorInputModifierNode) {
node.update(state, clipboard, enabled)
node.update(state, clipboard, enabled, formattingShortcuts)
}

override fun InspectorInfo.inspectableProperties() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import com.darkrockstudios.texteditor.CharLineOffset
import com.darkrockstudios.texteditor.TextEditorRange
import com.darkrockstudios.texteditor.clipboard.ClipboardHelper
import com.darkrockstudios.texteditor.input.TextEditorKeyCommandHandler.Companion.TAB_SIZE
import com.darkrockstudios.texteditor.state.TextEditorState
import com.darkrockstudios.texteditor.state.getSpanStylesInRange
import com.darkrockstudios.texteditor.state.moveCursorDown
import com.darkrockstudios.texteditor.state.moveCursorPageDown
import com.darkrockstudios.texteditor.state.moveCursorPageUp
Expand Down Expand Up @@ -49,7 +51,8 @@ internal class TextEditorKeyCommandHandler {
state: TextEditorState,
clipboard: Clipboard,
scope: CoroutineScope,
enabled: Boolean = true
enabled: Boolean = true,
formattingShortcuts: TextFormattingShortcuts = TextFormattingShortcuts.Default
): Boolean {
if (keyEvent.type != KeyEventType.KeyDown) return false

Expand Down Expand Up @@ -108,6 +111,32 @@ internal class TextEditorKeyCommandHandler {
// Editing operations require enabled=true
!enabled -> false

// Formatting shortcuts. Ctrl+Shift+X (strikethrough) is matched before
// the Ctrl+X cut branch so it isn't swallowed as a cut.
keyEvent.isCtrlPressed && keyEvent.isShiftPressed && keyEvent.key == Key.X &&
formattingShortcuts.strikethrough != null -> {
toggleStyleSpan(state, formattingShortcuts.strikethrough)
true
}

keyEvent.isCtrlPressed && keyEvent.key == Key.B &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ LOW RISK

Suggestion: Restrict these shortcuts to only trigger when Shift is not pressed.

Suggested change
keyEvent.isCtrlPressed && keyEvent.key == Key.B &&
keyEvent.isCtrlPressed && !keyEvent.isShiftPressed && keyEvent.key == Key.B &&

formattingShortcuts.bold != null -> {
toggleStyleSpan(state, formattingShortcuts.bold)
true
}

keyEvent.isCtrlPressed && keyEvent.key == Key.I &&
formattingShortcuts.italic != null -> {
toggleStyleSpan(state, formattingShortcuts.italic)
true
}

keyEvent.isCtrlPressed && keyEvent.key == Key.E &&
formattingShortcuts.code != null -> {
toggleStyleSpan(state, formattingShortcuts.code)
true
}

keyEvent.isCtrlPressed && keyEvent.key == Key.X -> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH RISK

To prevent accidental 'Cut' operations when strikethrough shortcuts are disabled or when other Shift+X combinations are used, ensure the Cut handler explicitly checks that Shift is not pressed.

This might be a simple fix:

Suggested change
keyEvent.isCtrlPressed && keyEvent.key == Key.X -> {
keyEvent.isCtrlPressed && !keyEvent.isShiftPressed && keyEvent.key == Key.X -> {

handleCut(state, clipboard, scope)
true
Expand Down Expand Up @@ -210,6 +239,19 @@ internal class TextEditorKeyCommandHandler {
return true
}

private fun toggleStyleSpan(state: TextEditorState, style: SpanStyle) {
val selection = state.selector.selection
if (selection != null) {
if (state.getSpanStylesInRange(selection).contains(style)) {
state.removeStyleSpan(selection, style)
} else {
state.addStyleSpan(selection, style)
}
} else {
state.cursor.toggleStyle(style)
}
}

private fun handleCopy(state: TextEditorState, clipboard: Clipboard, scope: CoroutineScope) {
state.selector.selection?.let { selection ->
val selectedText = state.selector.getSelectedText()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.darkrockstudios.texteditor.input

import androidx.compose.ui.text.SpanStyle
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration

/**
* Maps formatting keyboard shortcuts to the [SpanStyle] each one toggles over the
* selection (or the cursor for subsequent typing). A `null` style leaves that
* shortcut unbound.
*
* The default styles come from [MarkdownConfiguration.DEFAULT] so toggled spans
* match the markdown toolbar and round-trip through the markdown extension. If you
* render with a customized [MarkdownConfiguration], pass the matching styles here.
*
* Bindings (matching the editor's Ctrl-based shortcut convention):
* - [bold]: Ctrl+B
* - [italic]: Ctrl+I
* - [strikethrough]: Ctrl+Shift+X
* - [code]: Ctrl+E
*/
data class TextFormattingShortcuts(
val bold: SpanStyle? = MarkdownConfiguration.DEFAULT.boldStyle,
val italic: SpanStyle? = MarkdownConfiguration.DEFAULT.italicStyle,
val strikethrough: SpanStyle? = MarkdownConfiguration.DEFAULT.strikethroughStyle,
val code: SpanStyle? = MarkdownConfiguration.DEFAULT.codeStyle,
) {
companion object {
/** Bold, italic, strikethrough, and inline code bound to the markdown defaults. */
val Default = TextFormattingShortcuts()

/** Every formatting shortcut unbound. */
val None = TextFormattingShortcuts(
bold = null,
italic = null,
strikethrough = null,
code = null,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package input

import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.platform.Clipboard
import com.darkrockstudios.texteditor.CharLineOffset
import com.darkrockstudios.texteditor.TextEditorRange
import com.darkrockstudios.texteditor.input.TextEditorKeyCommandHandler
import com.darkrockstudios.texteditor.input.TextFormattingShortcuts
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
import com.darkrockstudios.texteditor.state.TextEditorState
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class FormattingShortcutTest {

private val bold = MarkdownConfiguration.DEFAULT.boldStyle
private val italic = MarkdownConfiguration.DEFAULT.italicStyle
private val strikethrough = MarkdownConfiguration.DEFAULT.strikethroughStyle
private val code = MarkdownConfiguration.DEFAULT.codeStyle

private val handler = TextEditorKeyCommandHandler()
private val clipboard = mockk<Clipboard>(relaxed = true)
private val scope = TestScope()

private lateinit var state: TextEditorState

@BeforeTest
fun setup() {
state = TextEditorState(
scope = TestScope(),
measurer = mockk(relaxed = true),
initialText = null,
)
state.setText("Hello World")
}

@OptIn(InternalComposeUiApi::class)
private fun keyEvent(key: Key, ctrl: Boolean = true, shift: Boolean = false): KeyEvent =
KeyEvent(
key = key,
type = KeyEventType.KeyDown,
isCtrlPressed = ctrl,
isShiftPressed = shift,
)

private fun selectWorld() {
state.selector.updateSelection(CharLineOffset(0, 6), CharLineOffset(0, 11))
}

private fun dispatch(key: Key, shift: Boolean = false, enabled: Boolean = true): Boolean =
handler.handleKeyEvent(keyEvent(key, shift = shift), state, clipboard, scope, enabled)

@Test
fun `Ctrl+B applies bold to the selection`() {
selectWorld()
val consumed = dispatch(Key.B)

assertTrue(consumed)
val spans = state.textLines[0].spanStyles
assertEquals(1, spans.size)
assertEquals(bold, spans.first().item)
assertEquals(6, spans.first().start)
assertEquals(11, spans.first().end)
}

@Test
fun `Ctrl+B toggles bold off when already applied`() {
selectWorld()
dispatch(Key.B)
selectWorld()
dispatch(Key.B)

assertTrue(state.textLines[0].spanStyles.none { it.item == bold })
}

@Test
fun `Ctrl+I applies italic to the selection`() {
selectWorld()
assertTrue(dispatch(Key.I))
assertTrue(state.textLines[0].spanStyles.any { it.item == italic })
}

@Test
fun `Ctrl+E applies inline code to the selection`() {
selectWorld()
assertTrue(dispatch(Key.E))
assertTrue(state.textLines[0].spanStyles.any { it.item == code })
}

@Test
fun `Ctrl+Shift+X applies strikethrough and does not cut`() {
selectWorld()
assertTrue(dispatch(Key.X, shift = true))
assertEquals("Hello World", state.textLines[0].text)
assertTrue(state.textLines[0].spanStyles.any { it.item == strikethrough })
}

@Test
fun `Ctrl+B with no selection sets a pending cursor style`() {
assertTrue(dispatch(Key.B))
assertTrue(state.cursor.styles.contains(bold))
assertTrue(state.textLines[0].spanStyles.isEmpty())
}

@Test
fun `formatting shortcuts do nothing when disabled`() {
selectWorld()
val consumed = handler.handleKeyEvent(keyEvent(Key.B), state, clipboard, scope, enabled = false)

assertFalse(consumed)
assertTrue(state.textLines[0].spanStyles.isEmpty())
}

@Test
fun `unbound shortcut is ignored`() {
selectWorld()
val consumed = handler.handleKeyEvent(
keyEvent(Key.B),
state,
clipboard,
scope,
enabled = true,
formattingShortcuts = TextFormattingShortcuts.None,
)

assertFalse(consumed)
assertTrue(state.textLines[0].spanStyles.isEmpty())
}
}
Loading