diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/BasicTextEditor.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/BasicTextEditor.kt index af760fd..11099a1 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/BasicTextEditor.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/BasicTextEditor.kt @@ -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 @@ -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( @@ -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) @@ -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) { diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/TextEditor.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/TextEditor.kt index 7514520..8db53f6 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/TextEditor.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/TextEditor.kt @@ -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 @@ -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( @@ -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( @@ -47,6 +53,7 @@ fun TextEditor( autoFocus = autoFocus, style = style, onRichSpanClick = onRichSpanClick, + formattingShortcuts = formattingShortcuts, ) } } diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorInputModifierNode.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorInputModifierNode.kt index 6945026..fe5052d 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorInputModifierNode.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorInputModifierNode.kt @@ -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, @@ -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 { @@ -103,7 +104,7 @@ 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 @@ -111,11 +112,13 @@ internal class TextEditorInputModifierNode( fun update( state: TextEditorState, clipboard: Clipboard, - enabled: Boolean + enabled: Boolean, + formattingShortcuts: TextFormattingShortcuts ) { this.state = state this.clipboard = clipboard this.enabled = enabled + this.formattingShortcuts = formattingShortcuts } } @@ -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() { 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() { diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt index ba0edb1..fafcad9 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorKeyCommandHandler.kt @@ -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 @@ -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 @@ -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 && + 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 -> { handleCut(state, clipboard, scope) true @@ -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() diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextFormattingShortcuts.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextFormattingShortcuts.kt new file mode 100644 index 0000000..4ce4c5d --- /dev/null +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/TextFormattingShortcuts.kt @@ -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, + ) + } +} diff --git a/ComposeTextEditor/src/desktopTest/kotlin/input/FormattingShortcutTest.kt b/ComposeTextEditor/src/desktopTest/kotlin/input/FormattingShortcutTest.kt new file mode 100644 index 0000000..02a0ac9 --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/input/FormattingShortcutTest.kt @@ -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(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()) + } +}