Skip to content

Commit 0d24144

Browse files
committed
feat: implement custom Compose terminal surface for SQL console
- Create `feature:console:ui` module providing a themed, multi-platform terminal surface. - Implement `ConsoleSurface` using a hybrid rendering strategy: a `Canvas` with `TextMeasurer` for read-only history and a `BasicTextField` for active input. - Add `ConsoleBuffer` and `ConsoleBufferBuilder` to map presentation transcript entries into renderable lines with semantic roles (PROMPT, COMMAND, OUTPUT, ERROR, etc.). - Implement `ConsolePromptVisualTransformation` to inject `...>` continuation prompts on multi-line inputs while maintaining correct cursor mapping. - Add `ConsoleStatementAnalyzer` with heuristics to decide between statement execution or line continuation on Enter. - Define `ConsoleTheme` derived from `MaterialTheme` to ensure the console adapts to light/dark modes and user preferences. - Refactor `ConsoleScreen` in `:ui:shared` to delegate the terminal body to the new `ConsoleSurface`. - Migrate and colocate console-related test tags to the new UI module. - Add unit tests for buffer building, statement analysis, and visual transformation offsets.
1 parent 14f3473 commit 0d24144

28 files changed

Lines changed: 1249 additions & 137 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
### Project Structure & Module Organization
1111
- Core: `core/domain`, `core/presentation`, `core/data/db-sqldelight` (default), `core/data/db-room` (optional), `core/test`.
12+
- Features: `feature/backup/{domain,ui}`, `feature/console/{domain,presentation,ui}`, `feature/file-explorer/data`.
1213
- UI: `ui/shared` (common Compose code and resources), `ui/test` (multiplatform Compose UI tests), `ui/test-jvm` (JVM-specific UI test utilities).
1314
- Apps: `app/android`, `app/desktop`, `app/web`, `app/ios-kit` (CocoaPods framework), `app/iosApp` (Xcode project).
1415
- Tooling: `build-logic` (Gradle conventions), `thirdparty` (vendored modules), `gradle/libs.versions.toml` (versions).
Binary file not shown.

feature/console/README.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,28 @@ Use case behavior:
2626
- Session-only history (not persisted across restarts)
2727
- Ignores submit while already running
2828

29+
### feature:console:ui
30+
Custom Compose-Multiplatform terminal surface (Android/JVM/iOS/wasmJs).
31+
- `ConsoleSurface` — public composable; the body of the console screen
32+
- `ConsoleBuffer` / `ConsoleBufferBuilder` — read-only scrollback model
33+
- `ConsolePromptVisualTransformation` — adds ` ...> ` continuation prompt after every `\n` in the input
34+
- `ConsoleStatementAnalyzer` — heuristic completeness check (trim → empty false → `.` prefix true → `;` suffix true)
35+
- `ConsoleTheme` / `rememberConsoleTheme()` — Material3-derived palette and typography (no hardcoded colors)
36+
- Internal renderers in `render/`: `ConsoleHistoryCanvas` (Canvas + `TextMeasurer`) and `ConsoleInputRow` (`BasicTextField` + Run button)
37+
38+
The module is resource-free — the surrounding `ConsoleScreen` in `:ui:shared` resolves strings via `stringResource(...)` and passes them as plain parameters. See `feature/console/ui/README.md` for design rationale and roadmap.
39+
2940
## Limitations
3041

3142
`SafeRepo.execute()` returns only the first column of the first row as `String?`. Full result sets are not available through the console.
3243

3344
## UI
3445

35-
The `ConsoleScreen` composable lives in `ui:shared` alongside other settings detail screens. It includes:
46+
The `ConsoleScreen` composable lives in `ui:shared` alongside other settings detail screens. It owns the Scaffold/TopAppBar, tips dropdown, helper text, progress indicator, and Koin view-model wiring; the terminal body itself is delegated to `ConsoleSurface` from `:feature:console:ui`. Together they provide:
3647
- Top app bar with "Console tips" overflow menu
37-
- Terminal-style transcript with color-coded entries
38-
- `sqlite>` prompt with text input and Run button
48+
- A custom Canvas-rendered scrollback with theme-aware colors
49+
- Multi-line input with `sqlite>` prompt and ` ...> ` continuation prompt
50+
- Run button preserves the existing test contract
3951

4052
## Testing
4153

@@ -47,8 +59,11 @@ The `ConsoleScreen` composable lives in `ui:shared` alongside other settings det
4759
# ViewModel tests
4860
./gradlew :feature:console:presentation:allTests
4961

50-
# Compose UI tests
51-
./gradlew :ui:shared:jvmTest
62+
# UI module unit tests (buffer, statement analyzer, visual transformation)
63+
./gradlew :feature:console:ui:jvmTest
64+
65+
# Compose UI tests (ConsoleScreen)
66+
./gradlew :ui:test-jvm:jvmTest
5267

5368
# Full build
5469
./gradle/build_quick.sh

feature/console/ui/README.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# feature:console:ui
2+
3+
A custom Compose-Multiplatform terminal surface for the SQL console. Targets `jvm`, `android`,
4+
`iosArm64`, `iosSimulatorArm64`, and `wasmJs`.
5+
6+
The module hosts only **pure rendering primitives** — no string resources, no Koin, no
7+
navigation. The screen frame (`Scaffold`, `TopAppBar`, tips dropdown, view-model wiring) lives
8+
in `:ui:shared`, which resolves strings via `stringResource(...)` and passes them as plain
9+
parameters into [`ConsoleSurface`](src/commonMain/kotlin/com/softartdev/notedelight/feature/console/ui/ConsoleSurface.kt).
10+
This split keeps the new module resource-free and minimizes refactor churn.
11+
12+
## Public API
13+
14+
```kotlin
15+
@Composable
16+
fun ConsoleSurface(
17+
buffer: ConsoleBuffer, // read-only history (built from ConsoleResult.transcript)
18+
inputText: String, // current editable input
19+
running: Boolean, // true while a statement is executing
20+
runContentDescription: String, // a11y label for the Run button (resolved string)
21+
placeholder: String, // empty-input placeholder (resolved string)
22+
onInputChange: (String) -> Unit,
23+
onExecute: () -> Unit,
24+
modifier: Modifier = Modifier,
25+
)
26+
```
27+
28+
`ConsoleBufferBuilder.build(transcript)` maps the presentation-layer transcript into a renderable
29+
`ConsoleBuffer`.
30+
31+
## Rendering strategy
32+
33+
A pure `Canvas` would force us to reimplement IME, caret, focus, and software-keyboard support
34+
on every platform — a trap. Instead, the surface is a **hybrid**:
35+
36+
1. **History** is drawn as a single `Canvas` painted via `rememberTextMeasurer()` and `drawText`.
37+
Each `ConsoleBufferLine` becomes an `AnnotatedString` with per-`ConsoleSegmentRole` color
38+
spans derived from `ConsoleTheme`. Canvas text has no built-in semantics, so the Canvas
39+
carries `contentDescription = buffer.plainText()` to keep screen-reader / text-based test
40+
queries working.
41+
2. **Active input** is a `BasicTextField` directly under the Canvas in the same scrolling
42+
`Column`, sharing typography and palette so it visually reads as the last line of the
43+
transcript. Compose's native caret handles selection and IME for free *within* the input.
44+
3. The whole surface is wrapped in a Material3 `Surface` with `theme.surfaceColor` and a
45+
`theme.outlineColor` border — no hardcoded background, so it adapts cleanly to light and
46+
dark themes.
47+
48+
## Statement-completeness heuristic
49+
50+
`ConsoleStatementAnalyzer.isComplete(raw)`:
51+
52+
1. `trim()` first.
53+
2. Empty → incomplete.
54+
3. Starts with `.` → complete (dot-command).
55+
4. Ends with `;` → complete.
56+
5. Otherwise → incomplete.
57+
58+
Pressing Enter on a complete statement dispatches `ConsoleAction.Submit`; pressing Enter on an
59+
incomplete one inserts a newline, which `ConsolePromptVisualTransformation` decorates with the
60+
` ...> ` continuation prompt.
61+
62+
The Run button submits unconditionally — it's the explicit affordance and preserves the existing
63+
test contract.
64+
65+
## How to add a new `ConsoleSegmentRole`
66+
67+
1. Add the variant to [`ConsoleSegmentRole`](src/commonMain/kotlin/com/softartdev/notedelight/feature/console/ui/buffer/ConsoleSegmentRole.kt).
68+
2. Add a matching color field to [`ConsoleTheme`](src/commonMain/kotlin/com/softartdev/notedelight/feature/console/ui/theme/ConsoleTheme.kt) and source it from `MaterialTheme.colorScheme` in `rememberConsoleTheme()`.
69+
3. Wire the role in the `when` inside `ConsoleHistoryCanvas.toAnnotatedString(theme)`.
70+
4. Update [`ConsoleBufferBuilder`](src/commonMain/kotlin/com/softartdev/notedelight/feature/console/ui/buffer/ConsoleBufferBuilder.kt) to emit the new role.
71+
72+
## Test tags
73+
74+
Surface-scoped tags are colocated with the composables that expose them:
75+
76+
```kotlin
77+
const val CONSOLE_INPUT_FIELD_TAG = "CONSOLE_INPUT_FIELD_TAG"
78+
const val CONSOLE_RUN_BUTTON_TAG = "CONSOLE_RUN_BUTTON_TAG"
79+
const val CONSOLE_TRANSCRIPT_TAG = "CONSOLE_TRANSCRIPT_TAG"
80+
```
81+
82+
Tips-menu tags (`CONSOLE_TIPS_BUTTON_TAG`, `CONSOLE_TIP_COPY_PREFIX`,
83+
`CONSOLE_TIP_AUTOFILL_PREFIX`) stay in `com.softartdev.notedelight.util.TestTags` because the
84+
tips dropdown lives in `:ui:shared`.
85+
86+
## Known limitations
87+
88+
- History is Canvas-drawn and not tap-selectable yet. The renderer is parameterized for
89+
selection (the structure is in place) — wiring tap-and-drag selection across lines is the
90+
next planned enhancement.
91+
- No SQL syntax highlighting — every `COMMAND` segment shares one color.
92+
- `.help` / `.tables` / `.schema` dot-commands are forwarded to the data layer as-is and will
93+
error; a shell layer in `:feature:console:domain` is required to dispatch them.
94+
- Up/Down arrow keys do not cycle `commandHistory` yet.
95+
- Statement completeness is character-based — `;` inside string literals or comments isn't
96+
detected.
97+
- `ConsolePromptVisualTransformation` operates on the string, not the layout, so long lines
98+
that wrap visually without an explicit `\n` do not trigger a continuation prompt.
99+
100+
## Roadmap
101+
102+
1. Tap-and-drag selection across history (`ConsoleHistoryCanvas` is parameterizable for it) +
103+
Copy toolbar action.
104+
2. Up/Down arrow → `ConsoleAction.HistoryPrev` / `HistoryNext`.
105+
3. Pluggable syntax-aware segmenter for `COMMAND` lines (keyword color).
106+
4. Shell layer in `:feature:console:domain` recognising `.help`, `.tables`, `.schema`, `.clear`.
107+
5. Stream multi-row / multi-column outputs (lift `safeRepo.execute(...)` from `String?` to a
108+
structured result).
109+
6. Virtualize the history Canvas (clip to viewport) once buffers grow large enough to matter.
110+
111+
## Tests
112+
113+
```bash
114+
./gradlew :feature:console:ui:jvmTest
115+
```
116+
117+
Three test files cover the pure pieces:
118+
- `buffer/ConsoleBufferBuilderTest` — command splitting, role mapping, empty transcript.
119+
- `input/ConsoleStatementAnalyzerTest` — trim handling, `;` tail, `.` prefix, blank, multi-line.
120+
- `input/ConsolePromptVisualTransformationTest` — offset-mapping round-trip and edge cases.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
@file:OptIn(ExperimentalWasmDsl::class)
2+
3+
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
4+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
5+
6+
plugins {
7+
alias(libs.plugins.kotlin.multiplatform)
8+
alias(libs.plugins.android.kotlin.multiplatform.library)
9+
alias(libs.plugins.compose)
10+
alias(libs.plugins.compose.compiler)
11+
}
12+
13+
kotlin {
14+
jvmToolchain(libs.versions.jdk.get().toInt())
15+
jvm {
16+
compilerOptions.jvmTarget = JvmTarget.fromTarget(libs.versions.jdk.get())
17+
}
18+
android {
19+
namespace = "com.softartdev.notedelight.feature.console.ui"
20+
compileSdk = libs.versions.compileSdk.get().toInt()
21+
minSdk = libs.versions.minSdk.get().toInt()
22+
compilerOptions {
23+
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jdk.get()))
24+
}
25+
}
26+
iosArm64()
27+
iosSimulatorArm64()
28+
wasmJs {
29+
browser()
30+
}
31+
sourceSets {
32+
val commonMain by getting {
33+
dependencies {
34+
implementation(projects.feature.console.domain)
35+
implementation(projects.feature.console.presentation)
36+
implementation(libs.compose.ui)
37+
implementation(libs.compose.runtime)
38+
implementation(libs.compose.foundation)
39+
implementation(libs.compose.material3)
40+
implementation(libs.compose.material.icons.extended)
41+
}
42+
}
43+
val commonTest by getting {
44+
dependencies {
45+
implementation(kotlin("test"))
46+
}
47+
}
48+
}
49+
compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes")
50+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.softartdev.notedelight.feature.console.ui
2+
3+
import androidx.compose.foundation.BorderStroke
4+
import androidx.compose.foundation.gestures.detectTapGestures
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.fillMaxWidth
7+
import androidx.compose.foundation.layout.imePadding
8+
import androidx.compose.foundation.layout.padding
9+
import androidx.compose.foundation.rememberScrollState
10+
import androidx.compose.foundation.verticalScroll
11+
import androidx.compose.material3.Surface
12+
import androidx.compose.runtime.Composable
13+
import androidx.compose.runtime.LaunchedEffect
14+
import androidx.compose.runtime.remember
15+
import androidx.compose.runtime.withFrameNanos
16+
import androidx.compose.ui.Modifier
17+
import androidx.compose.ui.focus.FocusRequester
18+
import androidx.compose.ui.input.pointer.pointerInput
19+
import androidx.compose.ui.unit.dp
20+
import com.softartdev.notedelight.feature.console.ui.buffer.ConsoleBuffer
21+
import com.softartdev.notedelight.feature.console.ui.render.ConsoleHistoryCanvas
22+
import com.softartdev.notedelight.feature.console.ui.render.ConsoleInputRow
23+
import com.softartdev.notedelight.feature.console.ui.theme.ConsoleTheme
24+
import com.softartdev.notedelight.feature.console.ui.theme.rememberConsoleTheme
25+
26+
/**
27+
* Public terminal surface. Drop-in body for a screen: the caller owns the Scaffold/TopAppBar and
28+
* any screen-scoped resources (strings, view model), and passes resolved strings into this
29+
* composable as plain parameters. That keeps this module resource-free while still presenting a
30+
* fully terminal-like experience.
31+
*
32+
* Composition:
33+
* - A themed [Surface] (no hardcoded black — [ConsoleTheme.surfaceColor] comes from Material3)
34+
* wraps the whole terminal and carries the rounded shape + 1.dp outline that gives it a
35+
* distinct "panel" appearance in both light and dark themes.
36+
* - Inside, a single scrolling [Column] contains the read-only [ConsoleHistoryCanvas] followed
37+
* directly by the active [ConsoleInputRow]. The two share typography and palette from
38+
* [ConsoleTheme] so visually the input is the last line of the transcript.
39+
* - [FocusRequester] is requested on first composition so the keyboard pops without an extra
40+
* tap. A transparent tap-gesture detector on the surface re-focuses the input when the user
41+
* taps anywhere in the history — a common terminal affordance.
42+
* - [Modifier.imePadding] prevents the Android soft keyboard from hiding the input line.
43+
* - Auto-scroll fires on either (a) the buffer growing (execution appended history) or (b) the
44+
* input gaining/losing a `\n` (continuation line added/removed), keeping the caret visible.
45+
*/
46+
@Composable
47+
fun ConsoleSurface(
48+
buffer: ConsoleBuffer,
49+
inputText: String,
50+
running: Boolean,
51+
runContentDescription: String,
52+
placeholder: String,
53+
onInputChange: (String) -> Unit,
54+
onExecute: () -> Unit,
55+
modifier: Modifier = Modifier,
56+
) {
57+
val theme: ConsoleTheme = rememberConsoleTheme()
58+
val focusRequester: FocusRequester = remember { FocusRequester() }
59+
val scrollState = rememberScrollState()
60+
61+
// Auto-focus the input on entry so the keyboard pops without an extra tap.
62+
// Wait one frame so the surrounding layout (verticalScroll Column) is placed before the
63+
// BasicTextField's internal BringIntoViewRequester reacts to focus — otherwise it throws
64+
// "Expected BringIntoViewRequester to not be used before parents are placed."
65+
LaunchedEffect(Unit) {
66+
withFrameNanos { }
67+
runCatching { focusRequester.requestFocus() }
68+
}
69+
70+
// Auto-scroll to the bottom whenever history or multi-line input grows/shrinks.
71+
val inputNewlineCount: Int = inputText.count { it == '\n' }
72+
LaunchedEffect(buffer.lineCount, inputNewlineCount, running) {
73+
scrollState.scrollTo(scrollState.maxValue)
74+
}
75+
76+
Surface(
77+
color = theme.surfaceColor,
78+
border = BorderStroke(width = 1.dp, color = theme.outlineColor),
79+
shape = theme.shapes.medium,
80+
modifier = modifier
81+
.fillMaxWidth()
82+
.imePadding(),
83+
) {
84+
Column(
85+
modifier = Modifier
86+
.fillMaxWidth()
87+
.verticalScroll(scrollState)
88+
.padding(horizontal = 12.dp, vertical = 8.dp)
89+
.pointerInput(focusRequester) {
90+
detectTapGestures(onTap = {
91+
runCatching { focusRequester.requestFocus() }
92+
})
93+
},
94+
) {
95+
ConsoleHistoryCanvas(
96+
buffer = buffer,
97+
theme = theme,
98+
)
99+
ConsoleInputRow(
100+
inputText = inputText,
101+
running = running,
102+
theme = theme,
103+
focusRequester = focusRequester,
104+
runContentDescription = runContentDescription,
105+
placeholder = placeholder,
106+
onInputChange = onInputChange,
107+
onExecute = onExecute,
108+
)
109+
}
110+
}
111+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.softartdev.notedelight.feature.console.ui
2+
3+
/**
4+
* Test tags for the console surface composables. Colocated with the composables that expose
5+
* them so `:ui:shared` does not have to depend on console-internal identifiers.
6+
*
7+
* Tips-menu tags (`CONSOLE_TIPS_BUTTON_TAG`, `CONSOLE_TIP_COPY_PREFIX`,
8+
* `CONSOLE_TIP_AUTOFILL_PREFIX`) stay in `com.softartdev.notedelight.util.TestTags` because the
9+
* tips dropdown lives in `:ui:shared`.
10+
*/
11+
const val CONSOLE_INPUT_FIELD_TAG: String = "CONSOLE_INPUT_FIELD_TAG"
12+
const val CONSOLE_RUN_BUTTON_TAG: String = "CONSOLE_RUN_BUTTON_TAG"
13+
const val CONSOLE_TRANSCRIPT_TAG: String = "CONSOLE_TRANSCRIPT_TAG"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.softartdev.notedelight.feature.console.ui.buffer
2+
3+
/**
4+
* Read-only scrollback model. The active (editable) input line is deliberately *not* part of
5+
* the buffer — it lives in a sibling [androidx.compose.foundation.text.BasicTextField] — so the
6+
* buffer represents only committed history.
7+
*/
8+
data class ConsoleBuffer(val lines: List<ConsoleBufferLine>) {
9+
10+
val lineCount: Int get() = lines.size
11+
12+
val isEmpty: Boolean get() = lines.isEmpty()
13+
14+
/**
15+
* Plain text representation of the whole buffer, joined with `\n`. Used as the
16+
* `contentDescription` for the Canvas-drawn history so screen readers and text-based test
17+
* queries can still see the content (Canvas text has no default semantics).
18+
*/
19+
fun plainText(): String = lines.joinToString(separator = "\n") { it.rawText }
20+
21+
companion object {
22+
val EMPTY: ConsoleBuffer = ConsoleBuffer(lines = emptyList())
23+
}
24+
}

0 commit comments

Comments
 (0)