ComposeTextEditor 3.0.2 and a render-time editor text size (#973)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run

* Update ComposeTextEditor to 3.0.2

Styles move to TextEditorState.richTextStyles, headings use the block API so
they export as headings, and shortcuts match on layoutKey. Markdown pins
ParagraphSeparator.NEWLINE to keep the 2.8 on-disk format.

* Make the editor text size a render property

Rich text styles are a fixed set with em-relative headings, so the body size
lives on the editor text style and changing it rewrites no document. The
size steps through a ladder, shows its value in the format bar menu, and
answers Ctrl+Plus, Minus and 0.
This commit is contained in:
Adam Brown 2026-10-03 13:36:53 -07:00 • committed by GitHub
parent 5d363ee7c4
commit ca22ec262d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 528 additions and 389 deletions

View file

@ -35,6 +35,7 @@
<string name="expand">Expand</string>
<string name="collapse">Collapse</string>
<string name="markdown_format_bar_text_size">Text size</string>
<string name="markdown_format_bar_decrease_text_size">Decrease text size</string>
<string name="markdown_format_bar_increase_text_size">Increase text size</string>
<string name="markdown_format_bar_reset_text_size">Reset text size</string>

View file

@ -3,17 +3,15 @@ package com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneedito
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
import korlibs.memory.clamp
val MIN_FONT_SIZE = 8f
val MAX_FONT_SIZE = 32f
/** The body text sizes the editor steps through. A size off the ladder snaps to it on the next step. */
val EDITOR_TEXT_SIZES = listOf(12f, 14f, 16f, 18f, 20f, 24f, 28f, 32f)
fun increaseEditorTextSize(currentSize: Float): Float {
return (currentSize + 1f).clamp(MIN_FONT_SIZE, MAX_FONT_SIZE)
}
fun increaseEditorTextSize(currentSize: Float): Float =
EDITOR_TEXT_SIZES.firstOrNull { it > currentSize } ?: EDITOR_TEXT_SIZES.last()
fun decreaseEditorTextSize(currentSize: Float): Float {
return (currentSize - 1f).clamp(MIN_FONT_SIZE, MAX_FONT_SIZE)
}
fun decreaseEditorTextSize(currentSize: Float): Float =
EDITOR_TEXT_SIZES.lastOrNull { it < currentSize } ?: EDITOR_TEXT_SIZES.first()
fun clampEditorWidth(width: Float): Float {
return width.clamp(GlobalSettings.MIN_EDITOR_WIDTH, GlobalSettings.MAX_EDITOR_WIDTH)
}
}

View file

@ -0,0 +1,30 @@
package components.storyeditor.sceneeditor
import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.EDITOR_TEXT_SIZES
import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.decreaseEditorTextSize
import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.increaseEditorTextSize
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class EditorTextSizeStepsTest {
@Test
fun `steps walk the ladder`() {
assertEquals(18f, increaseEditorTextSize(16f))
assertEquals(14f, decreaseEditorTextSize(16f))
}
@Test
fun `a size off the ladder snaps to it`() {
assertEquals(18f, increaseEditorTextSize(17f))
assertEquals(16f, decreaseEditorTextSize(17f))
}
@Test
fun `steps stop at the ends`() {
assertEquals(EDITOR_TEXT_SIZES.last(), increaseEditorTextSize(EDITOR_TEXT_SIZES.last()))
assertEquals(EDITOR_TEXT_SIZES.first(), decreaseEditorTextSize(EDITOR_TEXT_SIZES.first()))
assertEquals(EDITOR_TEXT_SIZES.last(), increaseEditorTextSize(99f))
assertEquals(EDITOR_TEXT_SIZES.first(), decreaseEditorTextSize(1f))
}
}

View file

@ -95,6 +95,7 @@ kotlin {
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose)
implementation(libs.compose.texteditor)
implementation(libs.compose.texteditor.markdown)
implementation(libs.compose.texteditor.find)
implementation(libs.compose.texteditor.spellcheck)
implementation(libs.platform.spellcheckerkt)

View file

@ -1,6 +1,7 @@
package com.darkrockstudios.apps.hammer.common.compose
import androidx.compose.ui.text.AnnotatedString
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.data.PlatformRichText
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
import com.darkrockstudios.texteditor.markdown.toMarkdown
@ -16,7 +17,7 @@ data class ComposeRichText(
override fun convertToMarkdown(): String {
return when {
snapshot != null -> snapshot.toMarkdown()
snapshot != null -> snapshot.toMarkdown(HammerMarkdownConfiguration)
state != null -> state.exportAsMarkdown()
else -> error("ComposeRichText must contain non-null data ")
}

View file

@ -1,28 +0,0 @@
package com.darkrockstudios.apps.hammer.common.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import com.darkrockstudios.apps.hammer.common.compose.markdown.changeFontSize
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
val LocalMarkdownConfig = compositionLocalOf {
MarkdownConfiguration.DEFAULT
}
@Composable
fun ProvideMarkdownConfig(
isDark: Boolean,
settings: GlobalSettings,
content: @Composable () -> Unit
) {
val baseMarkdownConfig =
if (isDark) MarkdownConfiguration.DEFAULT_DARK else MarkdownConfiguration.DEFAULT
val scaledMarkdownConfig = baseMarkdownConfig.changeFontSize(settings.editorFontSize)
CompositionLocalProvider(
LocalMarkdownConfig provides scaledMarkdownConfig,
content = content
)
}

View file

@ -0,0 +1,55 @@
package com.darkrockstudios.apps.hammer.common.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
import com.darkrockstudios.texteditor.RichTextStyles
/**
* Heading sizes are relative to the editor's text style, so the body size lives in
* [LocalEditorTextStyle] alone and changing it rewrites no document.
*/
val HammerRichTextStyles = RichTextStyles(
defaultTextStyle = SpanStyle(),
header1Style = SpanStyle(fontSize = 2.em, fontWeight = FontWeight.Bold),
header2Style = SpanStyle(fontSize = 1.5.em, fontWeight = FontWeight.Bold),
header3Style = SpanStyle(fontSize = 1.17.em, fontWeight = FontWeight.Bold),
header4Style = SpanStyle(fontSize = 1.em, fontWeight = FontWeight.Bold),
header5Style = SpanStyle(fontSize = 0.83.em, fontWeight = FontWeight.Bold),
header6Style = SpanStyle(fontSize = 0.75.em, fontWeight = FontWeight.Bold),
)
val HammerRichTextStylesDark = HammerRichTextStyles.copy(
codeStyle = RichTextStyles.DEFAULT_DARK.codeStyle,
highlightStyle = RichTextStyles.DEFAULT_DARK.highlightStyle,
)
val LocalRichTextStyles = compositionLocalOf { HammerRichTextStyles }
/** The editor's body text style; every rich text size scales from it. */
val LocalEditorTextStyle = compositionLocalOf {
TextStyle(fontSize = GlobalSettings.DEFAULT_FONT_SIZE.sp)
}
@Composable
fun ProvideRichTextStyles(
isDark: Boolean,
settings: GlobalSettings,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalRichTextStyles provides if (isDark) HammerRichTextStylesDark else HammerRichTextStyles,
LocalEditorTextStyle provides TextStyle(fontSize = settings.editorFontSize.sp),
content = content
)
}
/** A first-line indent that scales with the body size. */
fun TextStyle.withParagraphIndent(): TextStyle = copy(textIndent = TextIndent(firstLine = 1.5.em))

View file

@ -2,8 +2,12 @@ package com.darkrockstudios.apps.hammer.common.compose
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.*
import com.darkrockstudios.texteditor.input.layoutKey
/** Matches a key-down with exactly these modifiers: extra ones do not count as a match. */
/**
* Matches a key-down with exactly these modifiers: extra ones do not count as a match.
* Letters match on [layoutKey], as the editor's built-in chords do.
*/
fun KeyEvent.matchesShortcut(
key: Key,
ctrl: Boolean = false,
@ -12,7 +16,7 @@ fun KeyEvent.matchesShortcut(
): Boolean {
val ctrlOrMeta = isCtrlPressed || isMetaPressed
return type == KeyEventType.KeyDown &&
this.key == key &&
layoutKey == key &&
ctrlOrMeta == ctrl &&
isShiftPressed == shift &&
isAltPressed == alt
@ -47,3 +51,18 @@ fun Modifier.italicShortcutModifier(onItalic: () -> Unit): Modifier =
fun Modifier.strikethroughShortcutModifier(onStrikethrough: () -> Unit): Modifier =
onKeyShortcut(Key.X, ctrl = true, shift = true, action = onStrikethrough)
/** Ctrl/Cmd with Plus, Minus and 0, as browsers zoom. Plus is Shift+Equals on most layouts. */
fun Modifier.textSizeShortcutModifier(
onIncrease: () -> Unit,
onDecrease: () -> Unit,
onReset: () -> Unit,
): Modifier = this
.onKeyShortcut(Key.Equals, ctrl = true, action = onIncrease)
.onKeyShortcut(Key.Equals, ctrl = true, shift = true, action = onIncrease)
.onKeyShortcut(Key.Plus, ctrl = true, action = onIncrease)
.onKeyShortcut(Key.NumPadAdd, ctrl = true, action = onIncrease)
.onKeyShortcut(Key.Minus, ctrl = true, action = onDecrease)
.onKeyShortcut(Key.NumPadSubtract, ctrl = true, action = onDecrease)
.onKeyShortcut(Key.Zero, ctrl = true, action = onReset)
.onKeyShortcut(Key.NumPad0, ctrl = true, action = onReset)

View file

@ -1,114 +0,0 @@
package com.darkrockstudios.apps.hammer.common.compose.markdown
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.unit.sp
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
import io.github.aakira.napier.Napier
internal fun MarkdownConfiguration.changeFontSize(
newBaseFontSize: Float
): MarkdownConfiguration {
// Determine the baseline font size to adjust from
val baselineFontSize = defaultTextStyle.fontSize.value
if (baselineFontSize == newBaseFontSize) return this
Napier.d("Font size OLD: $baselineFontSize NEW: $newBaseFontSize")
val scaleFactor = newBaseFontSize / baselineFontSize
// Create a new configuration with scaled font sizes
// The key is to handle each property individually without relying on the copy method's handling of colors
return MarkdownConfiguration(
defaultTextStyle = defaultTextStyle.copy(
fontSize = newBaseFontSize.sp
),
boldStyle = SpanStyle(
fontSize = newBaseFontSize.sp,
fontWeight = boldStyle.fontWeight,
color = boldStyle.color,
fontFamily = boldStyle.fontFamily,
fontStyle = boldStyle.fontStyle,
background = boldStyle.background
),
italicStyle = SpanStyle(
fontSize = newBaseFontSize.sp,
fontWeight = italicStyle.fontWeight,
color = italicStyle.color,
fontFamily = italicStyle.fontFamily,
fontStyle = italicStyle.fontStyle,
background = italicStyle.background
),
codeStyle = SpanStyle(
fontSize = newBaseFontSize.sp,
fontWeight = codeStyle.fontWeight,
color = codeStyle.color,
fontFamily = codeStyle.fontFamily,
fontStyle = codeStyle.fontStyle,
background = codeStyle.background
),
linkStyle = SpanStyle(
fontSize = newBaseFontSize.sp,
fontWeight = linkStyle.fontWeight,
color = linkStyle.color,
fontFamily = linkStyle.fontFamily,
fontStyle = linkStyle.fontStyle,
background = linkStyle.background
),
blockquoteStyle = SpanStyle(
fontSize = newBaseFontSize.sp,
fontWeight = blockquoteStyle.fontWeight,
color = blockquoteStyle.color,
fontFamily = blockquoteStyle.fontFamily,
fontStyle = blockquoteStyle.fontStyle,
background = blockquoteStyle.background
),
header1Style = SpanStyle(
fontSize = (header1Style.fontSize.value * scaleFactor).sp,
fontWeight = header1Style.fontWeight,
color = header1Style.color,
fontFamily = header1Style.fontFamily,
fontStyle = header1Style.fontStyle,
background = header1Style.background
),
header2Style = SpanStyle(
fontSize = (header2Style.fontSize.value * scaleFactor).sp,
fontWeight = header2Style.fontWeight,
color = header2Style.color,
fontFamily = header2Style.fontFamily,
fontStyle = header2Style.fontStyle,
background = header2Style.background
),
header3Style = SpanStyle(
fontSize = (header3Style.fontSize.value * scaleFactor).sp,
fontWeight = header3Style.fontWeight,
color = header3Style.color,
fontFamily = header3Style.fontFamily,
fontStyle = header3Style.fontStyle,
background = header3Style.background
),
header4Style = SpanStyle(
fontSize = (header4Style.fontSize.value * scaleFactor).sp,
fontWeight = header4Style.fontWeight,
color = header4Style.color,
fontFamily = header4Style.fontFamily,
fontStyle = header4Style.fontStyle,
background = header4Style.background
),
header5Style = SpanStyle(
fontSize = (header5Style.fontSize.value * scaleFactor).sp,
fontWeight = header5Style.fontWeight,
color = header5Style.color,
fontFamily = header5Style.fontFamily,
fontStyle = header5Style.fontStyle,
background = header5Style.background
),
header6Style = SpanStyle(
fontSize = (header6Style.fontSize.value * scaleFactor).sp,
fontWeight = header6Style.fontWeight,
color = header6Style.color,
fontFamily = header6Style.fontFamily,
fontStyle = header6Style.fontStyle,
background = header6Style.background
)
)
}

View file

@ -0,0 +1,10 @@
package com.darkrockstudios.apps.hammer.common.compose.markdown
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
import com.darkrockstudios.texteditor.markdown.ParagraphSeparator
/**
* One editor line per markdown line. The library default, BLANK_LINE, drops the blank lines
* between paragraphs on import and rewrites single-newline paragraphs on export.
*/
val HammerMarkdownConfiguration = MarkdownConfiguration(paragraphSeparator = ParagraphSeparator.NEWLINE)

View file

@ -3,36 +3,34 @@ package com.darkrockstudios.apps.hammer.common.compose.markdown
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
import com.darkrockstudios.texteditor.RichTextStyles
import com.darkrockstudios.texteditor.state.TextEditorState
internal fun MarkdownExtension.updateMarkdownConfiguration(newConfig: MarkdownConfiguration) {
val oldConfig = markdownConfiguration
internal fun TextEditorState.updateRichTextStyles(newStyles: RichTextStyles) {
updateMarkdownStyles(
state = editorState,
oldConfig = oldConfig,
newConfig = newConfig
state = this,
oldConfig = richTextStyles,
newConfig = newStyles
)
markdownConfiguration = newConfig
richTextStyles = newStyles
}
/**
* Updates all text in the editor to use the new markdown configuration styles.
* Updates all text in the editor to use the new styles.
* This preserves the semantic meaning of styles while updating their visual appearance.
*
* @param state The TextEditorState to update
* @param oldConfig The previous configuration that was used
* @param newConfig The new configuration to apply
* @param oldConfig The previous styles that were used
* @param newConfig The new styles to apply
*/
internal fun updateMarkdownStyles(
state: TextEditorState,
oldConfig: MarkdownConfiguration,
newConfig: MarkdownConfiguration
oldConfig: RichTextStyles,
newConfig: RichTextStyles
) {
if (state.textLines.isEmpty()) return
// Heading styles are deliberately absent: MarkdownExtension.markdownConfiguration's
// Heading styles are deliberately absent: TextEditorState.richTextStyles'
// setter rebakes them from their HeaderSpanStyle spans. Remapping them here too
// leaves the rebake unable to find the old style to strip, so it appends a second
// copy to every heading on each config change.
@ -42,6 +40,7 @@ internal fun updateMarkdownStyles(
oldConfig.italicStyle to newConfig.italicStyle,
oldConfig.codeStyle to newConfig.codeStyle,
oldConfig.linkStyle to newConfig.linkStyle,
oldConfig.highlightStyle to newConfig.highlightStyle,
oldConfig.blockquoteStyle to newConfig.blockquoteStyle,
)
@ -98,5 +97,8 @@ private fun deepCompareSpanStyles(style1: SpanStyle, style2: SpanStyle): Boolean
if (style1.baselineShift != style2.baselineShift) return false
if (style1.textGeometricTransform != style2.textGeometricTransform) return false
if (style1.localeList != style2.localeList) return false
// What tells a heading's look from an inline style it equals (RichTextStyles.headingLook)
if (style1.platformStyle != style2.platformStyle) return false
if (style1.drawStyle != style2.drawStyle) return false
return true
}

View file

@ -11,19 +11,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.rememberKoinInject
import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent
import com.darkrockstudios.apps.hammer.common.spellcheck.SpellCheckRepository
import com.darkrockstudios.apps.hammer.common.utils.toEditorSpellChecker
import com.darkrockstudios.texteditor.markdown.withMarkdown
import com.darkrockstudios.texteditor.rememberTextEditorStyle
import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor
import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown
import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState
/**
@ -46,7 +46,7 @@ fun MarkdownEditField(
minEditorHeight: Dp = 200.dp,
testTag: String? = null,
) {
val markdownConfig = LocalMarkdownConfig.current
val richTextStyles = LocalRichTextStyles.current
val spellCheckRepository = rememberKoinInject<SpellCheckRepository>()
val platformSpellChecker by spellCheckRepository.dictionaryFlow.collectAsState(initial = null)
@ -61,7 +61,10 @@ fun MarkdownEditField(
// so a null checker also means "disabled" — clear decorations rather than keep checking.
enableSpellChecking = enableSpellChecking && platformSpellChecker != null,
)
val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) }
val markdownExtension = remember {
textEditorState.textState.richTextStyles = richTextStyles
textEditorState.textState.withMarkdown(HammerMarkdownConfiguration)
}
LaunchedEffect(markdownExtension) {
markdownExtension.importMarkdown(initialMarkdown)
@ -72,8 +75,8 @@ fun MarkdownEditField(
}
}
LaunchedEffect(markdownConfig) {
markdownExtension.updateMarkdownConfiguration(markdownConfig)
LaunchedEffect(richTextStyles) {
textEditorState.textState.updateRichTextStyles(richTextStyles)
}
Column(
@ -91,9 +94,7 @@ fun MarkdownEditField(
enabled = enabled,
autoFocus = autoFocus,
style = rememberTextEditorStyle(
textStyle = TextStyle.Default.copy(
textIndent = TextIndent(firstLine = 24.sp)
)
textStyle = LocalEditorTextStyle.current.withParagraphIndent()
),
contentPadding = contentPadding,
modifier = Modifier

View file

@ -2,10 +2,13 @@ package com.darkrockstudios.apps.hammer.common.compose.markdowneditor
import com.darkrockstudios.texteditor.CharLineOffset
import com.darkrockstudios.texteditor.TextEditorRange
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
import com.darkrockstudios.texteditor.richstyle.HR_PLACEHOLDER
import com.darkrockstudios.texteditor.richstyle.HorizontalRuleSpanStyle
import com.darkrockstudios.texteditor.state.TextEditorState
import com.darkrockstudios.texteditor.state.headerLevel
import com.darkrockstudios.texteditor.state.toggleBulletList
import com.darkrockstudios.texteditor.state.toggleHeader
import com.darkrockstudios.texteditor.state.toggleOrderedList
internal fun insertHorizontalRule(state: TextEditorState) {
state.insertNewlineAtCursor()
@ -49,49 +52,33 @@ internal fun reconcileHorizontalRules(state: TextEditorState) {
}
}
internal fun toggleBulletList(state: TextEditorState, markdown: MarkdownExtension) {
private fun selectedLines(state: TextEditorState): IntRange {
val selection = state.selector.selection
val lines = if (selection != null) {
return if (selection != null) {
selection.start.line..selection.end.line
} else {
state.cursorPosition.line..state.cursorPosition.line
}
markdown.toggleBulletList(lines)
}
internal fun toggleOrderedList(state: TextEditorState, markdown: MarkdownExtension) {
val selection = state.selector.selection
val lines = if (selection != null) {
selection.start.line..selection.end.line
} else {
state.cursorPosition.line..state.cursorPosition.line
}
markdown.toggleOrderedList(lines)
internal fun toggleBulletList(state: TextEditorState) {
state.toggleBulletList(selectedLines(state))
}
internal fun toggleOrderedList(state: TextEditorState) {
state.toggleOrderedList(selectedLines(state))
}
internal val HEADER_CYCLE_LEVELS = 1..3
internal fun cycleHeader(
state: TextEditorState,
markdown: MarkdownExtension,
currentLevel: Int,
) {
val maxLevel = HEADER_CYCLE_LEVELS.last
val nextLevel = (currentLevel + 1) % (maxLevel + 1)
val selection = state.selector.selection
if (selection != null) {
HEADER_CYCLE_LEVELS.forEach { lvl ->
state.removeStyleSpan(selection, markdown.markdownStyles.header(lvl))
}
if (nextLevel != 0) {
state.addStyleSpan(selection, markdown.markdownStyles.header(nextLevel))
internal fun cycleHeader(state: TextEditorState, currentLevel: Int) {
val lines = selectedLines(state)
if (currentLevel >= HEADER_CYCLE_LEVELS.last) {
// toggleHeader only removes a level every line already has, so clear each line by its own
lines.forEach { line ->
state.headerLevel(line)?.let { state.toggleHeader(line..line, it) }
}
} else {
HEADER_CYCLE_LEVELS.forEach { lvl ->
state.cursor.removeStyle(markdown.markdownStyles.header(lvl))
}
if (nextLevel != 0) {
state.cursor.addStyle(markdown.markdownStyles.header(nextLevel))
}
state.toggleHeader(lines, currentLevel + 1)
}
}

View file

@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
@ -21,6 +23,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
@ -33,10 +36,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.darkrockstudios.apps.hammer.Res
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
import com.darkrockstudios.apps.hammer.common.compose.boldShortcutModifier
import com.darkrockstudios.apps.hammer.common.compose.icons.EditorIcons
import com.darkrockstudios.apps.hammer.common.compose.icons.IconBold
@ -50,6 +56,7 @@ import com.darkrockstudios.apps.hammer.common.compose.icons.IconUndo
import com.darkrockstudios.apps.hammer.common.compose.italicShortcutModifier
import com.darkrockstudios.apps.hammer.common.compose.resources.get
import com.darkrockstudios.apps.hammer.common.compose.strikethroughShortcutModifier
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
import com.darkrockstudios.apps.hammer.markdown_format_bar_blockquote
import com.darkrockstudios.apps.hammer.markdown_format_bar_bold
import com.darkrockstudios.apps.hammer.markdown_format_bar_bullet_list
@ -62,6 +69,7 @@ import com.darkrockstudios.apps.hammer.markdown_format_bar_italic
import com.darkrockstudios.apps.hammer.markdown_format_bar_numbered_list
import com.darkrockstudios.apps.hammer.markdown_format_bar_redo
import com.darkrockstudios.apps.hammer.markdown_format_bar_reset_text_size
import com.darkrockstudios.apps.hammer.markdown_format_bar_text_size
import com.darkrockstudios.apps.hammer.markdown_format_bar_strikethrough
import com.darkrockstudios.apps.hammer.markdown_format_bar_undo
import com.darkrockstudios.apps.hammer.more_menu_button
@ -72,6 +80,7 @@ import com.darkrockstudios.texteditor.state.TextEditorState
import com.darkrockstudios.texteditor.state.getRichSpansAtPosition
import com.darkrockstudios.texteditor.state.getRichSpansInRange
import com.darkrockstudios.texteditor.state.getSpanStylesInRange
import com.darkrockstudios.texteditor.state.headerLevel
@Composable
fun MarkdownFormatBar(
@ -105,15 +114,13 @@ fun MarkdownFormatBar(
state.getRichSpansAtPosition(position)
}
isBoldActive = styles.contains(markdownState.markdownStyles.BOLD)
isItalicActive = styles.contains(markdownState.markdownStyles.ITALICS)
isStrikethroughActive = styles.contains(markdownState.markdownStyles.STRIKETHROUGH)
isBlockquoteActive = styles.contains(markdownState.markdownStyles.BLOCKQUOTE)
isBoldActive = styles.contains(state.richTextStyles.boldStyle)
isItalicActive = styles.contains(state.richTextStyles.italicStyle)
isStrikethroughActive = styles.contains(state.richTextStyles.strikethroughStyle)
isBlockquoteActive = styles.contains(state.richTextStyles.blockquoteStyle)
isBulletListActive = richSpans.any { it.style === BulletListSpanStyle }
isOrderedListActive = richSpans.any { it.style === OrderedListSpanStyle }
currentHeaderLevel = HEADER_CYCLE_LEVELS.firstOrNull { lvl ->
styles.contains(markdownState.markdownStyles.header(lvl))
} ?: 0
currentHeaderLevel = state.headerLevel(selection?.start?.line ?: position.line) ?: 0
}
}
@ -134,7 +141,6 @@ fun MarkdownFormatBar(
Row(modifier = rowModifier) {
FormatButtons(
state = state,
markdownState = markdownState,
isBoldActive = isBoldActive,
isItalicActive = isItalicActive,
isStrikethroughActive = isStrikethroughActive,
@ -167,7 +173,6 @@ private val TOOLBAR_COMPACT_THRESHOLD = 520.dp
@Composable
private fun RowScope.FormatButtons(
state: TextEditorState,
markdownState: MarkdownExtension,
isBoldActive: Boolean,
isItalicActive: Boolean,
isStrikethroughActive: Boolean,
@ -181,7 +186,7 @@ private fun RowScope.FormatButtons(
icon = EditorIcons.IconBold,
active = isBoldActive,
) {
toggleStyle(state, markdownState.markdownStyles.BOLD)
toggleStyle(state, state.richTextStyles.boldStyle)
}
}
EditorTooltip("${Res.string.markdown_format_bar_italic.get()} (${shortcutHint("I")})") {
@ -189,7 +194,7 @@ private fun RowScope.FormatButtons(
icon = EditorIcons.IconItalic,
active = isItalicActive,
) {
toggleStyle(state, markdownState.markdownStyles.ITALICS)
toggleStyle(state, state.richTextStyles.italicStyle)
}
}
EditorTooltip(
@ -204,7 +209,7 @@ private fun RowScope.FormatButtons(
icon = EditorIcons.IconStrikethrough,
active = isStrikethroughActive,
) {
toggleStyle(state, markdownState.markdownStyles.STRIKETHROUGH)
toggleStyle(state, state.richTextStyles.strikethroughStyle)
}
}
EditorTooltip(Res.string.markdown_format_bar_heading.get()) {
@ -212,7 +217,7 @@ private fun RowScope.FormatButtons(
label = if (currentHeaderLevel == 0) "H" else "H$currentHeaderLevel",
active = currentHeaderLevel != 0,
) {
cycleHeader(state, markdownState, currentHeaderLevel)
cycleHeader(state, currentHeaderLevel)
}
}
EditorTooltip(Res.string.markdown_format_bar_blockquote.get()) {
@ -220,7 +225,7 @@ private fun RowScope.FormatButtons(
icon = Icons.Default.FormatQuote,
active = isBlockquoteActive,
) {
toggleStyle(state, markdownState.markdownStyles.BLOCKQUOTE)
toggleStyle(state, state.richTextStyles.blockquoteStyle)
}
}
EditorTooltip(Res.string.markdown_format_bar_bullet_list.get()) {
@ -228,7 +233,7 @@ private fun RowScope.FormatButtons(
icon = Icons.AutoMirrored.Filled.FormatListBulleted,
active = isBulletListActive,
) {
toggleBulletList(state, markdownState)
toggleBulletList(state)
}
}
EditorTooltip(Res.string.markdown_format_bar_numbered_list.get()) {
@ -236,7 +241,7 @@ private fun RowScope.FormatButtons(
icon = Icons.Default.FormatListNumbered,
active = isOrderedListActive,
) {
toggleOrderedList(state, markdownState)
toggleOrderedList(state)
}
}
EditorTooltip(Res.string.markdown_format_bar_horizontal_rule.get()) {
@ -321,49 +326,60 @@ private fun HistoryAndOverflow(
},
)
}
if (decreaseTextSize != null) {
DropdownMenuItem(
text = { Text(Res.string.markdown_format_bar_decrease_text_size.get()) },
leadingIcon = {
Icon(
imageVector = EditorIcons.IconTextDecrease,
contentDescription = null,
)
},
onClick = {
decreaseTextSize()
menuExpanded = false
},
if (decreaseTextSize != null || increaseTextSize != null || resetTextSize != null) {
TextSizeMenuRow(decreaseTextSize, increaseTextSize, resetTextSize)
}
}
}
}
/** Steps the body text size and shows it; the menu stays open while stepping. */
@Composable
private fun TextSizeMenuRow(
decreaseTextSize: (() -> Unit)?,
increaseTextSize: (() -> Unit)?,
resetTextSize: (() -> Unit)?,
) {
val textSize = LocalEditorTextStyle.current.fontSize.value
Row(
modifier = Modifier.padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = Res.string.markdown_format_bar_text_size.get(),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(end = 12.dp),
)
if (decreaseTextSize != null) {
IconButton(onClick = decreaseTextSize) {
Icon(
imageVector = EditorIcons.IconTextDecrease,
contentDescription = Res.string.markdown_format_bar_decrease_text_size.get(),
)
}
if (increaseTextSize != null) {
DropdownMenuItem(
text = { Text(Res.string.markdown_format_bar_increase_text_size.get()) },
leadingIcon = {
Icon(
imageVector = EditorIcons.IconTextIncrease,
contentDescription = null,
)
},
onClick = {
increaseTextSize()
menuExpanded = false
},
}
Text(
text = textSize.toInt().toString(),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(min = 28.dp),
)
if (increaseTextSize != null) {
IconButton(onClick = increaseTextSize) {
Icon(
imageVector = EditorIcons.IconTextIncrease,
contentDescription = Res.string.markdown_format_bar_increase_text_size.get(),
)
}
if (resetTextSize != null) {
DropdownMenuItem(
text = { Text(Res.string.markdown_format_bar_reset_text_size.get()) },
leadingIcon = {
Icon(
imageVector = EditorIcons.IconTextReset,
contentDescription = null,
)
},
onClick = {
resetTextSize()
menuExpanded = false
},
}
if (resetTextSize != null) {
IconButton(
onClick = resetTextSize,
enabled = textSize != GlobalSettings.DEFAULT_FONT_SIZE,
) {
Icon(
imageVector = EditorIcons.IconTextReset,
contentDescription = Res.string.markdown_format_bar_reset_text_size.get(),
)
}
}
@ -379,9 +395,9 @@ private fun HistoryAndOverflow(
fun Modifier.markdownFormatShortcuts(markdownExtension: MarkdownExtension): Modifier {
val state = markdownExtension.editorState
return this
.boldShortcutModifier { toggleStyle(state, markdownExtension.markdownStyles.BOLD) }
.italicShortcutModifier { toggleStyle(state, markdownExtension.markdownStyles.ITALICS) }
.strikethroughShortcutModifier { toggleStyle(state, markdownExtension.markdownStyles.STRIKETHROUGH) }
.boldShortcutModifier { toggleStyle(state, state.richTextStyles.boldStyle) }
.italicShortcutModifier { toggleStyle(state, state.richTextStyles.italicStyle) }
.strikethroughShortcutModifier { toggleStyle(state, state.richTextStyles.strikethroughStyle) }
}
/**

View file

@ -8,11 +8,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.text
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent
import com.darkrockstudios.texteditor.RichTextView
import com.darkrockstudios.texteditor.markdown.withMarkdown
import com.darkrockstudios.texteditor.rememberTextEditorStyle
@ -32,9 +32,12 @@ fun MarkdownView(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
val markdownConfig = LocalMarkdownConfig.current
val richTextStyles = LocalRichTextStyles.current
val state = rememberTextEditorState()
val markdownExtension = remember(state) { state.withMarkdown(markdownConfig) }
val markdownExtension = remember(state) {
state.richTextStyles = richTextStyles
state.withMarkdown(HammerMarkdownConfiguration)
}
LaunchedEffect(markdownExtension, markdown) {
markdownExtension.importMarkdown(markdown)
}
@ -43,9 +46,7 @@ fun MarkdownView(
modifier = modifier.semantics { text = AnnotatedString(markdown) },
contentPadding = contentPadding,
style = rememberTextEditorStyle(
textStyle = TextStyle.Default.copy(
textIndent = TextIndent(firstLine = 24.sp)
)
textStyle = LocalEditorTextStyle.current.withParagraphIndent()
)
)
}

View file

@ -7,7 +7,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import com.darkrockstudios.apps.hammer.common.compose.ProvideMarkdownConfig
import com.darkrockstudios.apps.hammer.common.compose.ProvideRichTextStyles
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
@ -115,7 +115,7 @@ fun AppTheme(
shapes = HammerShapes,
typography = hammerTypography(),
) {
ProvideMarkdownConfig(isDark = useDarkTheme, settings = settings) {
ProvideRichTextStyles(isDark = useDarkTheme, settings = settings) {
content()
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
import com.darkrockstudios.apps.hammer.common.compose.WindowWidthSizeClass
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
@ -21,18 +22,20 @@ import com.darkrockstudios.apps.hammer.base.diff.OffsetMap
import com.darkrockstudios.apps.hammer.common.components.storyeditor.drafts.DraftCompare
import com.darkrockstudios.apps.hammer.common.compose.*
import com.darkrockstudios.apps.hammer.common.compose.designsystem.*
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.resources.get
import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors
import com.darkrockstudios.apps.hammer.common.data.SceneContent
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneDiffText
import com.darkrockstudios.texteditor.CharLineOffset
import com.darkrockstudios.texteditor.RichTextStyles
import com.darkrockstudios.texteditor.TextEditor
import com.darkrockstudios.texteditor.TextEditorRange
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
import com.darkrockstudios.texteditor.markdown.withMarkdown
import com.darkrockstudios.texteditor.rememberTextEditorStyle
import com.darkrockstudios.texteditor.richstyle.HighlightSpanStyle
import com.darkrockstudios.texteditor.richstyle.RichSpan
import com.darkrockstudios.texteditor.state.TextEditorState
@ -54,16 +57,16 @@ fun DraftCompareUi(component: DraftCompare) {
val screen = LocalScreenCharacteristic.current
val state by component.state.subscribeAsState()
val draftName = component.draftDef.draftName
val markdownConfig = LocalMarkdownConfig.current
val richTextStyles = LocalRichTextStyles.current
// Hoisted above the width branch: the two layouts are separate composables, so building
// the editors inside them would discard the user's merge edits on a resize or rotate
// while component.mergedContent still pointed at the torn-down editor.
val draftMarkdown = key(state.draftContent) {
rememberSceneContentEditor(state.draftContent, markdownConfig)
rememberSceneContentEditor(state.draftContent, richTextStyles)
}
val currentMarkdown = key(state.sceneContent) {
rememberSceneContentEditor(state.sceneContent, markdownConfig)
rememberSceneContentEditor(state.sceneContent, richTextStyles)
}
Column(modifier = Modifier.fillMaxSize()) {
@ -252,6 +255,7 @@ private fun DraftPane(
TextEditor(
modifier = Modifier.fillMaxSize(),
state = textEditorState,
style = rememberTextEditorStyle(textStyle = LocalEditorTextStyle.current),
enabled = false,
)
}
@ -324,6 +328,7 @@ private fun CurrentPane(
TextEditor(
modifier = Modifier.fillMaxSize(),
state = textEditorState,
style = rememberTextEditorStyle(textStyle = LocalEditorTextStyle.current),
)
}
}
@ -333,16 +338,17 @@ private fun CurrentPane(
@Composable
private fun rememberSceneContentEditor(
content: SceneContent?,
markdownConfig: MarkdownConfiguration,
richTextStyles: RichTextStyles,
): MarkdownExtension {
val editorState = rememberTextEditorState()
val markdownExtension = remember(editorState) {
editorState.withMarkdown(markdownConfig).also { loadSceneContent(it, content) }
editorState.richTextStyles = richTextStyles
editorState.withMarkdown(HammerMarkdownConfiguration).also { loadSceneContent(it, content) }
}
// Both panes restyle together; the config is otherwise baked in at import time and
// Both panes restyle together; the styles are otherwise baked in at import time and
// the two would drift apart on a font size or theme change.
LaunchedEffect(markdownExtension, markdownConfig) {
markdownExtension.updateMarkdownConfiguration(markdownConfig)
LaunchedEffect(markdownExtension, richTextStyles) {
editorState.updateRichTextStyles(richTextStyles)
}
return markdownExtension
}

View file

@ -30,25 +30,26 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.darkrockstudios.apps.hammer.Res
import com.darkrockstudios.apps.hammer.common.TextEditorDefaults
import com.darkrockstudios.apps.hammer.common.components.storyeditor.focusmode.FocusMode
import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.clampEditorWidth
import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText
import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.Ui
import com.darkrockstudios.apps.hammer.common.compose.findShortcutModifier
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.rememberDefaultDispatcher
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdResizeHandle
import com.darkrockstudios.apps.hammer.common.compose.designsystem.rememberHdResizeHandleState
import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownFormatBar
import com.darkrockstudios.apps.hammer.common.compose.resources.get
import com.darkrockstudios.apps.hammer.common.compose.textSizeShortcutModifier
import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent
import com.darkrockstudios.apps.hammer.common.data.UpdateSource
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneContentMarkdown
@ -58,9 +59,9 @@ import com.darkrockstudios.apps.hammer.scene_editor_add_to_dictionary
import com.darkrockstudios.apps.hammer.scene_editor_menu_item_close
import com.darkrockstudios.texteditor.find.FindBar
import com.darkrockstudios.texteditor.find.rememberFindState
import com.darkrockstudios.texteditor.markdown.withMarkdown
import com.darkrockstudios.texteditor.rememberTextEditorStyle
import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor
import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown
import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState
import kotlinx.coroutines.withContext
@ -68,7 +69,7 @@ import kotlinx.coroutines.withContext
fun FocusModeUi(component: FocusMode) {
val state by component.state.subscribeAsState()
val lastForceUpdate by component.lastForceUpdate.subscribeAsState()
val markdownConfig = LocalMarkdownConfig.current
val richTextStyles = LocalRichTextStyles.current
val defaultDispatcher = rememberDefaultDispatcher()
val addToDictionaryLabel = Res.string.scene_editor_add_to_dictionary.get()
@ -83,13 +84,16 @@ fun FocusModeUi(component: FocusMode) {
initialText = null,
enableSpellChecking = state.spellCheckingEnabled,
)
val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) }
val markdownExtension = remember {
textEditorState.textState.richTextStyles = richTextStyles
textEditorState.textState.withMarkdown(HammerMarkdownConfiguration)
}
val findState = rememberFindState(textEditorState.textState)
var showFindBar by remember { mutableStateOf(false) }
LaunchedEffect(markdownConfig) {
markdownExtension.updateMarkdownConfiguration(markdownConfig)
LaunchedEffect(richTextStyles) {
textEditorState.textState.updateRichTextStyles(richTextStyles)
}
var hasReceivedInitialBuffer by remember { mutableStateOf(false) }
@ -136,6 +140,11 @@ fun FocusModeUi(component: FocusMode) {
.fillMaxSize()
.focusModeChromePadding()
.findShortcutModifier { showFindBar = true }
.textSizeShortcutModifier(
onIncrease = component::increaseTextSize,
onDecrease = component::decreaseTextSize,
onReset = component::resetTextSize,
)
) {
Row(
modifier = Modifier
@ -201,9 +210,7 @@ fun FocusModeUi(component: FocusMode) {
enabled = hasReceivedInitialBuffer,
spellCheckMenuItems = spellCheckMenuItems,
style = rememberTextEditorStyle(
textStyle = TextStyle.Default.copy(
textIndent = TextIndent(firstLine = 24.sp)
),
textStyle = LocalEditorTextStyle.current.withParagraphIndent(),
focusedBorderColor = Color.Transparent,
),
modifier = Modifier

View file

@ -40,11 +40,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.darkrockstudios.apps.hammer.Res
import com.darkrockstudios.apps.hammer.common.TextEditorDefaults
@ -52,19 +49,23 @@ import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor
import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.clampEditorWidth
import com.darkrockstudios.apps.hammer.common.compose.AnimatedDialog
import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText
import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.RootSnackbarHostState
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.rememberDefaultDispatcher
import com.darkrockstudios.apps.hammer.common.compose.Toaster
import com.darkrockstudios.apps.hammer.common.compose.Ui
import com.darkrockstudios.apps.hammer.common.compose.findShortcutModifier
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdResizeHandle
import com.darkrockstudios.apps.hammer.common.compose.designsystem.rememberHdResizeHandleState
import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownFormatBar
import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.markdownFormatShortcuts
import com.darkrockstudios.apps.hammer.common.compose.resources.get
import com.darkrockstudios.apps.hammer.common.compose.saveShortcutModifier
import com.darkrockstudios.apps.hammer.common.compose.textSizeShortcutModifier
import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent
import com.darkrockstudios.apps.hammer.common.data.UpdateSource
import com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.SceneDeleteDialog
import com.darkrockstudios.apps.hammer.common.utils.addToDictionaryMenuItems
@ -72,10 +73,10 @@ import com.darkrockstudios.apps.hammer.common.utils.toEditorSpellChecker
import com.darkrockstudios.apps.hammer.scene_editor_add_to_dictionary
import com.darkrockstudios.texteditor.find.FindBar
import com.darkrockstudios.texteditor.find.rememberFindState
import com.darkrockstudios.texteditor.markdown.withMarkdown
import com.darkrockstudios.texteditor.rememberTextEditorStyle
import com.darkrockstudios.texteditor.spellcheck.SpellCheckMode
import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor
import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown
import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -93,7 +94,7 @@ fun SceneEditorUi(
) {
val state by component.state.subscribeAsState()
val lastForceUpdate by component.lastForceUpdate.subscribeAsState()
val markdownConfig = LocalMarkdownConfig.current
val richTextStyles = LocalRichTextStyles.current
val scope = rememberCoroutineScope()
val defaultDispatcher = rememberDefaultDispatcher()
@ -110,13 +111,16 @@ fun SceneEditorUi(
enableSpellChecking = state.spellCheckingEnabled,
spellCheckMode = SpellCheckMode.Word,
)
val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) }
val markdownExtension = remember {
textEditorState.textState.richTextStyles = richTextStyles
textEditorState.textState.withMarkdown(HammerMarkdownConfiguration)
}
val findState = rememberFindState(textEditorState.textState)
var showFindBar by remember { mutableStateOf(false) }
LaunchedEffect(markdownConfig) {
markdownExtension.updateMarkdownConfiguration(markdownConfig)
LaunchedEffect(richTextStyles) {
textEditorState.textState.updateRichTextStyles(richTextStyles)
}
var hasReceivedInitialBuffer by remember { mutableStateOf(false) }
@ -186,6 +190,11 @@ fun SceneEditorUi(
modifier = Modifier
.fillMaxHeight()
.findShortcutModifier { showFindBar = true }
.textSizeShortcutModifier(
onIncrease = component::increaseTextSize,
onDecrease = component::decreaseTextSize,
onReset = component::resetTextSize,
)
.saveShortcutModifier { scope.launch { component.storeSceneContent() } }
.markdownFormatShortcuts(markdownExtension)
) {
@ -237,9 +246,7 @@ fun SceneEditorUi(
enabled = hasReceivedInitialBuffer,
spellCheckMenuItems = spellCheckMenuItems,
style = rememberTextEditorStyle(
textStyle = TextStyle.Default.copy(
textIndent = TextIndent(firstLine = 24.sp)
),
textStyle = LocalEditorTextStyle.current.withParagraphIndent(),
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),

View file

@ -0,0 +1,53 @@
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.cycleHeader
import com.darkrockstudios.texteditor.CharLineOffset
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
import com.darkrockstudios.texteditor.markdown.withMarkdown
import com.darkrockstudios.texteditor.state.TextEditorState
import com.darkrockstudios.texteditor.state.headerLevel
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class CycleHeaderTest {
private fun TestScope.newEditor(): MarkdownExtension =
TextEditorState(scope = this, measurer = mockk(relaxed = true)).withMarkdown(HammerMarkdownConfiguration)
@Test
fun `cycling exports as a markdown heading`() = runTest {
val editor = newEditor()
editor.importMarkdown("Title\n\nBody")
cycleHeader(editor.editorState, currentLevel = 0)
assertEquals(1, editor.editorState.headerLevel(0))
assertEquals("# Title", editor.exportAsMarkdown().lineSequence().first())
}
@Test
fun `cycling past the last level removes the heading`() = runTest {
val editor = newEditor()
editor.importMarkdown("### Title")
cycleHeader(editor.editorState, currentLevel = 3)
assertNull(editor.editorState.headerLevel(0))
assertEquals("Title", editor.exportAsMarkdown().lineSequence().first())
}
@Test
fun `cycling past the last level clears a mixed selection`() = runTest {
val editor = newEditor()
editor.importMarkdown("### Title\nBody")
editor.editorState.selector.updateSelection(CharLineOffset(0, 0), CharLineOffset(1, 4))
cycleHeader(editor.editorState, currentLevel = 3)
assertNull(editor.editorState.headerLevel(0))
assertNull(editor.editorState.headerLevel(1))
}
}

View file

@ -0,0 +1,86 @@
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent
import com.darkrockstudios.texteditor.TextEditor
import com.darkrockstudios.texteditor.markdown.withMarkdown
import com.darkrockstudios.texteditor.rememberTextEditorStyle
import com.darkrockstudios.texteditor.state.TextEditorState
import com.darkrockstudios.texteditor.state.rememberTextEditorState
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
private const val DOC =
"# Title\n\n**bold** and *it* and [l](http://x.y) and `code`\n\n## Two\n\n> quote\n\n- a\n- b\n\n#### Four\n\nplain"
@OptIn(ExperimentalTestApi::class)
class EditorTextSizeTest {
@Test
fun `markdown round trips through the relative styles`() = runTest {
val state = TextEditorState(scope = this, measurer = mockk(relaxed = true))
state.richTextStyles = HammerRichTextStyles
val markdown = state.withMarkdown(HammerMarkdownConfiguration)
markdown.importMarkdown(DOC)
assertEquals(DOC, markdown.exportAsMarkdown())
}
@Test
fun `the body size scales every line without touching the document`() {
var bodySize by mutableStateOf(16f)
var state: TextEditorState? = null
runComposeUiTest {
setContent {
val editorState = rememberTextEditorState()
state = editorState
TextEditor(
state = editorState,
style = rememberTextEditorStyle(
textStyle = TextStyle(fontSize = bodySize.sp).withParagraphIndent(),
),
)
}
waitForIdle()
val editorState = state!!
editorState.richTextStyles = HammerRichTextStyles
editorState.withMarkdown(HammerMarkdownConfiguration).importMarkdown(DOC)
waitForIdle()
fun lineHeights() = editorState.lineOffsets
.filter { it.virtualLineIndex == 0 }
.associate { it.line to it.textLayoutResult.size.height }
fun indentOf(line: Int) = editorState.lineOffsets
.first { it.line == line && it.virtualLineIndex == 0 }
.textLayoutResult.getHorizontalPosition(0, usePrimaryDirection = true)
val small = lineHeights()
val smallIndent = indentOf(small.keys.max())
val linesBefore = editorState.textLines.toList()
bodySize = 32f
waitForIdle()
val large = lineHeights()
val largeIndent = indentOf(small.keys.max())
assertEquals(linesBefore, editorState.textLines.toList())
assertEquals(24f, smallIndent)
assertEquals(48f, largeIndent)
val lastLine = small.keys.max()
assertTrue(small.getValue(0) > small.getValue(lastLine) * 1.6f)
small.keys.forEach { line ->
val ratio = large.getValue(line).toFloat() / small.getValue(line)
assertTrue(ratio in 1.8f..2.2f)
}
}
}
}

View file

@ -1,45 +0,0 @@
import androidx.compose.ui.unit.sp
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
import com.darkrockstudios.texteditor.state.TextEditorState
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
class MarkdownConfigRestyleTest {
private fun TestScope.newEditor(config: MarkdownConfiguration): MarkdownExtension {
val state = TextEditorState(scope = this, measurer = mockk(relaxed = true))
return MarkdownExtension(state, config)
}
private fun configWithBodySize(size: Float) = MarkdownConfiguration.DEFAULT.copy(
defaultTextStyle = MarkdownConfiguration.DEFAULT.defaultTextStyle.copy(fontSize = size.sp),
header1Style = MarkdownConfiguration.DEFAULT.header1Style.copy(fontSize = (size * 2).sp),
)
@Test
fun `stepping the font size leaves one baked style per heading`() = runTest {
val editor = newEditor(configWithBodySize(16f))
editor.importMarkdown("# Chapter One\n\nSome body text.")
val before = editor.editorState.textLines[0].spanStyles.size
repeat(5) { step -> editor.updateMarkdownConfiguration(configWithBodySize(18f + step * 2)) }
assertEquals(before, editor.editorState.textLines[0].spanStyles.size)
}
@Test
fun `heading tracks the new font size`() = runTest {
val editor = newEditor(configWithBodySize(16f))
editor.importMarkdown("# Chapter One")
editor.updateMarkdownConfiguration(configWithBodySize(20f))
val sizes = editor.editorState.textLines[0].spanStyles.map { it.item.fontSize }
assertEquals(listOf(40f.sp), sizes.filter { it != 20f.sp })
}
}

View file

@ -1,8 +1,9 @@
import androidx.compose.ui.unit.sp
import com.darkrockstudios.apps.hammer.common.compose.markdown.changeFontSize
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStylesDark
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
import com.darkrockstudios.texteditor.markdown.withMarkdown
import com.darkrockstudios.texteditor.state.TextEditorState
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
@ -11,34 +12,25 @@ import org.junit.Test
import kotlin.test.assertEquals
/**
* Changing the editor font size restyles every line in place, which must not cost
* block lines the ParagraphStyle their gutter markers are positioned against.
* A theme change restyles every line in place, which must not cost block lines the
* ParagraphStyle their gutter markers are positioned against.
*/
class MarkdownRestyleBlockIndentTest {
private fun TestScope.newEditor(config: MarkdownConfiguration): MarkdownExtension =
MarkdownExtension(TextEditorState(scope = this, measurer = mockk(relaxed = true)), config)
private fun TestScope.newEditor(): MarkdownExtension {
val state = TextEditorState(scope = this, measurer = mockk(relaxed = true))
state.richTextStyles = HammerRichTextStyles
return state.withMarkdown(HammerMarkdownConfiguration)
}
@Test
fun `list indents survive a font size change`() = runTest {
val editor = newEditor(MarkdownConfiguration.DEFAULT)
fun `list indents survive a theme change`() = runTest {
val editor = newEditor()
editor.importMarkdown("A paragraph.\n\n1. one\n2. two\n\n- bullet\n\n> quoted")
val before = editor.editorState.textLines.map { it.paragraphStyles }
editor.updateMarkdownConfiguration(MarkdownConfiguration.DEFAULT.changeFontSize(24f))
editor.editorState.updateRichTextStyles(HammerRichTextStylesDark)
assertEquals(before, editor.editorState.textLines.map { it.paragraphStyles })
}
@Test
fun `body text tracks the new font size`() = runTest {
val editor = newEditor(MarkdownConfiguration.DEFAULT)
editor.importMarkdown("A paragraph.\n\n1. one")
editor.updateMarkdownConfiguration(MarkdownConfiguration.DEFAULT.changeFontSize(24f))
editor.editorState.textLines.filter { it.text.isNotEmpty() }.forEach { line ->
assertEquals(listOf(24f.sp), line.spanStyles.map { it.item.fontSize })
}
}
}

View file

@ -0,0 +1,51 @@
import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStyles
import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStylesDark
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
import com.darkrockstudios.texteditor.RichTextStyles
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
import com.darkrockstudios.texteditor.markdown.withMarkdown
import com.darkrockstudios.texteditor.state.TextEditorState
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** Switching themes restyles the document in place; the body size never does (see EditorTextSizeTest). */
class RichTextStylesRestyleTest {
private fun TestScope.newEditor(styles: RichTextStyles): MarkdownExtension {
val state = TextEditorState(scope = this, measurer = mockk(relaxed = true))
state.richTextStyles = styles
return state.withMarkdown(HammerMarkdownConfiguration)
}
@Test
fun `switching themes leaves one baked style per heading`() = runTest {
val editor = newEditor(HammerRichTextStyles)
editor.importMarkdown("# Chapter One\n\nSome body text.")
val before = editor.editorState.textLines[0].spanStyles.size
repeat(5) { step ->
editor.editorState.updateRichTextStyles(
if (step % 2 == 0) HammerRichTextStylesDark else HammerRichTextStyles
)
}
assertEquals(before, editor.editorState.textLines[0].spanStyles.size)
}
@Test
fun `code spans take the new theme's background`() = runTest {
val editor = newEditor(HammerRichTextStyles)
editor.importMarkdown("Some `code` here.")
editor.editorState.updateRichTextStyles(HammerRichTextStylesDark)
val backgrounds = editor.editorState.textLines[0].spanStyles.map { it.item.background }
assertTrue(HammerRichTextStylesDark.codeStyle.background in backgrounds)
assertTrue(HammerRichTextStyles.codeStyle.background !in backgrounds)
}
}

View file

@ -1,12 +1,13 @@
import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
import com.darkrockstudios.apps.hammer.common.data.SceneContent
import com.darkrockstudios.apps.hammer.common.data.SceneItem
import com.darkrockstudios.apps.hammer.common.fileio.HPath
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneDiffText
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
import com.darkrockstudios.texteditor.markdown.withMarkdown
import com.darkrockstudios.texteditor.richstyle.HorizontalRuleSpanStyle
import com.darkrockstudios.texteditor.state.TextEditorState
import io.mockk.mockk
@ -33,7 +34,7 @@ class SceneContentLoadTest {
private fun TestScope.newEditor(): MarkdownExtension {
val state = TextEditorState(scope = this, measurer = mockk(relaxed = true))
return MarkdownExtension(state, MarkdownConfiguration.DEFAULT)
return state.withMarkdown(HammerMarkdownConfiguration)
}
private fun MarkdownExtension.horizontalRuleLines(): List<Int> =

View file

@ -107,7 +107,7 @@ korge-core = "6.0.0"
bouncycastle = "1.86"
kotlinx-io-okio = "0.9.1"
compose-texteditor = "2.8.0"
compose-texteditor = "3.0.2"
kotlin-multiplatform-diff = "1.4.0"
platform-spellcheckerkt = "1.3.2"
nucleus = "2.5.18"
@ -344,6 +344,7 @@ moko-permissions-storage = { group = "dev.icerock.moko", name = "permissions-sto
moko-permissions-compose = { group = "dev.icerock.moko", name = "permissions-compose", version.ref = "moko_permissions" }
compose-texteditor = { group = "com.darkrockstudios", name = "composetexteditor", version.ref = "compose-texteditor" }
compose-texteditor-markdown = { group = "com.darkrockstudios", name = "composetexteditor-markdown", version.ref = "compose-texteditor" }
compose-texteditor-find = { group = "com.darkrockstudios", name = "composetexteditor-find", version.ref = "compose-texteditor" }
compose-texteditor-spellcheck = { group = "com.darkrockstudios", name = "composetexteditor-spellcheck", version.ref = "compose-texteditor" }