Shell: persistent sidebar (views, engine status, theme) + library top bar with search/sort/batch; responsive rail and drawer modes; Ctrl+F search

This commit is contained in:
avi 2026-09-21 12:35:19 -05:00
commit ceb3be3dda
4 changed files with 361 additions and 67 deletions

View file

@ -248,6 +248,8 @@ fun SonarField(
leadingIcon: (@Composable () -> Unit)? = null,
imeAction: ImeAction = ImeAction.Done,
onDone: () -> Unit = {},
/** Extra modifier for the text input itself (focus requester). */
inputModifier: Modifier = Modifier,
) {
var isFocused by androidx.compose.runtime.remember {
androidx.compose.runtime.mutableStateOf(false)
@ -278,7 +280,7 @@ fun SonarField(
cursorBrush = SolidColor(Shonar.Accent),
keyboardOptions = KeyboardOptions(imeAction = imeAction),
keyboardActions = KeyboardActions(onDone = { onDone() }),
modifier = Modifier.fillMaxWidth(),
modifier = inputModifier.fillMaxWidth(),
decorationBox = { inner ->
if (value.isEmpty()) {
Text(placeholder,

View file

@ -20,6 +20,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
@ -99,6 +102,7 @@ fun main() = application {
) {
val baseDensity = LocalDensity.current
val themeMode by state.themeMode.collectAsState()
val searchFocus = androidx.compose.ui.focus.FocusRequester()
ShonarTheme(mode = themeMode) {
Surface(
Modifier.fillMaxSize().onPreviewKeyEvent { e ->
@ -125,6 +129,11 @@ fun main() = application {
} else false
} else if (!e.isCtrlPressed) false
else when (e.key) {
Key.F -> {
if (screen == DesktopState.Screen.LIBRARY) {
searchFocus.requestFocus(); true
} else false
}
Key.Equals, Key.Plus, Key.NumPadAdd, Key.DirectionUp -> {
zoom = (zoom * 1.1f).coerceAtMost(3f); true
}
@ -145,7 +154,35 @@ fun main() = application {
CompositionLocalProvider(
LocalDensity provides ScaledDensity(baseDensity, zoom),
) {
Box(Modifier.fillMaxSize().padding(20.dp)) {
androidx.compose.foundation.layout.BoxWithConstraints(
Modifier.fillMaxSize(),
) {
val wide = maxWidth >= 1100.dp
val medium = maxWidth >= 760.dp
var drawerOpen by androidx.compose.runtime.remember {
mutableStateOf(false)
}
Row(Modifier.fillMaxSize().padding(
top = Shonar.Space4, bottom = Shonar.Space4,
start = 0.dp, end = Shonar.Space4)) {
if (medium) {
ShonarSidebar(
state = state,
collapsed = !wide,
)
Spacer(Modifier.width(Shonar.Space4))
}
Column(Modifier.fillMaxSize().weight(1f)) {
if (screen == DesktopState.Screen.LIBRARY) {
LibraryTopBar(
state = state,
showMenuButton = !medium,
onMenu = { drawerOpen = true },
searchFocus = searchFocus,
)
Spacer(Modifier.height(Shonar.Space3))
}
// Undo-delete toast sits above Library AND Detail
// (a delete from Detail lands back on Library,
// but the toast must appear either way).
@ -194,6 +231,25 @@ fun main() = application {
DesktopState.Screen.SETTINGS -> SettingsScreen(state)
}
}
} // content column
} // shell row
// Narrow screens: sidebar as an overlay drawer.
if (!medium && drawerOpen) {
androidx.compose.foundation.layout.Box(
Modifier.fillMaxSize()
.background(Color(0x99000000))
.clickable { drawerOpen = false },
) {
ShonarSidebar(
state = state,
collapsed = false,
onNavigate = { drawerOpen = false },
modifier = Modifier
.align(Alignment.CenterStart)
.padding(Shonar.Space2),
)
}
}
}
}
}

View file

@ -218,15 +218,6 @@ fun LibraryScreen(state: DesktopState) {
val selected by state.selected.collectAsState()
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(Shonar.Space3)) {
Row(verticalAlignment = Alignment.CenterVertically) {
ShonarWordmark(modifier = Modifier.weight(1f))
ShonarGhostButton({ state.go(DesktopState.Screen.SETTINGS) }) {
Icon(Icons.Filled.Settings, contentDescription = "Settings",
modifier = Modifier.size(Shonar.IconSm))
Spacer(Modifier.width(Shonar.Space2))
Text("Settings")
}
}
if (!connected) {
SonarCard(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding),
@ -239,25 +230,6 @@ fun LibraryScreen(state: DesktopState) {
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
val auto = state.autoTranscribe.collectAsState().value
Checkbox(auto, { state.setAutoTranscribe(it) })
Text("Auto-transcribe new files",
style = MaterialTheme.typography.bodySmall)
}
val batch = state.batch.collectAsState().value
val untranscribed = entries.count {
!it.hasReport && it.status != FileStatus.QUEUED &&
it.status != FileStatus.RUNNING
}
if (batch == null && untranscribed > 0) {
ShonarOutlinedButton({ state.transcribeAll() }) {
Text("Transcribe all ($untranscribed)")
}
}
}
// Batch progress: settled count + current file's own fraction.
val batch = state.batch.collectAsState().value
if (batch != null) {
@ -305,43 +277,6 @@ fun LibraryScreen(state: DesktopState) {
}
}
}
SonarField(query, { state.setQuery(it) },
placeholder = "Search saved transcripts…",
singleLine = true, modifier = Modifier.fillMaxWidth(),
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null,
tint = Shonar.TextSecondary,
modifier = Modifier.size(Shonar.IconSm))
})
// Sort order (persisted): name A–Z, newest first, oldest first.
// Date modes also surface the file date on each row below.
if (folder != null && query.isBlank()) {
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Text("Sort", style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
val order = state.sortOrder.collectAsState().value
listOf("name" to "Name", "new" to "Newest",
"old" to "Oldest").forEach { (value, label) ->
PillChip(
selected = order == value,
onClick = { state.setSortOrder(value) },
label = label,
)
}
Spacer(Modifier.width(Shonar.Space3))
Text("Show", style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
listOf("all" to "All", "new" to "Untranscribed")
.forEach { (value, label) ->
PillChip(
selected = statusFilter == value,
onClick = { state.setLibraryFilter(value) },
label = label,
)
}
}
}
if (selected.isNotEmpty() && query.isBlank()) {
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {

View file

@ -0,0 +1,301 @@
package com.shonar.desktop
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.material.icons.filled.Nightlight
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.focus.focusRequester
import androidx.compose.material.icons.filled.Search
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* The workspace shell: a persistent sidebar (library views, engine
* status, settings, theme) plus a context top bar (search, sort,
* batch actions) above the content region.
*
* Responsive:
* - ≥1100dp: full sidebar (labels + counts)
* - 760–1100dp: icon rail (labels move to accessibility names)
* - <760dp: sidebar hides; a menu button in the top bar opens it as
* an overlay drawer over the content.
*
* The sidebar's library views ARE the session filter lens (state.
* libraryFilter) — never persisted, so a stale lens can't hide files
* at startup. Detail / Engine / Settings keep their own headers; the
* top bar only carries library chrome.
*/
/** Library views offered by the sidebar; value = libraryFilter. */
private val LIBRARY_VIEWS = listOf(
"all" to "All recordings",
"new" to "Needs transcription",
"failed" to "Failed",
"done" to "Ready",
)
@Composable
fun ShonarSidebar(
state: DesktopState,
collapsed: Boolean, // icon rail mode
onNavigate: () -> Unit = {}, // closes the drawer on narrow screens
modifier: Modifier = Modifier,
) {
val screen by state.screen.collectAsState()
val filter by state.libraryFilter.collectAsState()
val connected by state.connected.collectAsState()
val allEntries by state.entries.collectAsState()
val counts = mapOf(
"all" to allEntries.size,
"new" to allEntries.count {
!it.hasReport && it.status != FileStatus.QUEUED &&
it.status != FileStatus.RUNNING && it.status != FileStatus.DONE
},
"failed" to allEntries.count { it.status == FileStatus.FAILED },
"done" to allEntries.count {
it.hasReport || it.status == FileStatus.DONE
},
)
Surface(
modifier = modifier.fillMaxHeight()
.width(if (collapsed) Shonar.RailW else Shonar.SidebarW),
color = Shonar.SurfaceAlt,
) {
Column(Modifier.fillMaxSize().padding(vertical = Shonar.Space3)) {
// Brand block: logo tile (+ wordmark when expanded).
if (collapsed) {
Box(Modifier.padding(horizontal = Shonar.Space3)
.padding(bottom = Shonar.Space3)) {
SonarLogo(size = Shonar.ControlH)
}
} else {
Row(
Modifier.padding(horizontal = Shonar.Space3)
.padding(bottom = Shonar.Space3),
verticalAlignment = Alignment.CenterVertically,
) {
SonarLogo(size = 26.dp)
Spacer(Modifier.width(Shonar.Space2))
Text("SHONAR",
style = MaterialTheme.typography.titleSmall,
color = Shonar.TextPrimary)
}
}
// Library views — the nav core.
LIBRARY_VIEWS.forEach { (value, label) ->
val selected = screen == DesktopState.Screen.LIBRARY &&
filter == value
NavRow(
selected = selected,
onClick = {
state.setLibraryFilter(value)
state.go(DesktopState.Screen.LIBRARY)
onNavigate()
},
label = label,
modifier = Modifier.padding(
horizontal = Shonar.Space2),
icon = {
val (img, name) = when (value) {
"new" -> Icons.Filled.Mic to "mic"
"failed" -> Icons.Filled.Error to "error"
"done" -> Icons.Filled.CheckCircle to "check"
else -> Icons.Filled.Folder to "folder"
}
Icon(img,
contentDescription = if (collapsed) label else null,
tint = if (selected) Shonar.Accent
else Shonar.TextMuted,
modifier = Modifier.size(Shonar.IconSm))
},
count = if (collapsed) null else counts[value],
)
}
Spacer(Modifier.height(Shonar.Space3))
// Engine status: live dot + label; click opens the engine
// screen (start/stop/logs). Honest state, always visible.
NavRow(
selected = screen == DesktopState.Screen.ENGINE,
onClick = {
state.go(DesktopState.Screen.ENGINE); onNavigate()
},
label = if (collapsed) "Engine" else
if (connected) "Engine · running" else "Engine · stopped",
modifier = Modifier.padding(horizontal = Shonar.Space2),
icon = {
StatusDot(
color = if (connected) Shonar.Accent
else Shonar.Error,
modifier = Modifier.padding(
horizontal = (Shonar.IconSm - 8.dp) / 2),
)
},
)
Spacer(Modifier.weight(1f))
// Settings + theme toggle pinned at the bottom.
NavRow(
selected = screen == DesktopState.Screen.SETTINGS,
onClick = {
state.go(DesktopState.Screen.SETTINGS); onNavigate()
},
label = "Settings",
modifier = Modifier.padding(horizontal = Shonar.Space2),
icon = {
Icon(Icons.Filled.Settings,
contentDescription =
if (collapsed) "Settings" else null,
tint = if (screen == DesktopState.Screen.SETTINGS)
Shonar.Accent else Shonar.TextMuted,
modifier = Modifier.size(Shonar.IconSm))
},
)
Row(
Modifier.fillMaxWidth()
.padding(horizontal = Shonar.Space2)
.padding(top = Shonar.Space1),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (collapsed)
Arrangement.Center else Arrangement.Start,
) {
val mode = state.themeMode.collectAsState().value
ShonarIconButton(
onClick = {
state.setThemeMode(
if (mode == ThemeMode.DARK) ThemeMode.LIGHT
else ThemeMode.DARK)
},
label = if (mode == ThemeMode.DARK)
"Switch to light theme" else "Switch to dark theme",
) {
Icon(
if (mode == ThemeMode.DARK) Icons.Filled.WbSunny
else Icons.Filled.Nightlight,
contentDescription = null,
tint = Shonar.TextSecondary,
modifier = Modifier.size(Shonar.IconSm))
}
}
}
}
}
/** Library toolbar: search, sort, batch action, status meta. Only
* drawn over the LIBRARY screen; Detail/Engine/Settings carry their
* own headers. [searchFocus] lets Ctrl+F jump into the field. */
@Composable
fun LibraryTopBar(
state: DesktopState,
showMenuButton: Boolean,
onMenu: () -> Unit,
searchFocus: FocusRequester,
modifier: Modifier = Modifier,
) {
val query by state.query.collectAsState()
val connected by state.connected.collectAsState()
Column(modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (showMenuButton) {
ShonarIconButton(onClick = onMenu, label = "Open navigation") {
Icon(Icons.Filled.Menu, contentDescription = null,
tint = Shonar.TextSecondary,
modifier = Modifier.size(Shonar.IconSm))
}
Spacer(Modifier.width(Shonar.Space2))
}
SonarField(query, { state.setQuery(it) },
placeholder = "Search saved transcripts…",
singleLine = true,
modifier = Modifier.fillMaxWidth().weight(1f),
inputModifier = Modifier.focusRequester(searchFocus),
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null,
tint = Shonar.TextSecondary,
modifier = Modifier.size(Shonar.IconSm))
})
Spacer(Modifier.width(Shonar.Space2))
ShonarIconButton(
onClick = {
state.go(DesktopState.Screen.SETTINGS)
},
label = "Settings",
) {
Icon(Icons.Filled.Settings, contentDescription = null,
tint = Shonar.TextSecondary,
modifier = Modifier.size(Shonar.IconSm))
}
}
// Sort + auto-transcribe + batch: second row, dense.
val batch = state.batch.collectAsState().value
val allEntries by state.entries.collectAsState()
val remaining = allEntries.count {
!it.hasReport && it.status != FileStatus.QUEUED &&
it.status != FileStatus.RUNNING
}
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Text("Sort", style = MaterialTheme.typography.labelMedium,
color = Shonar.TextSecondary)
val order = state.sortOrder.collectAsState().value
listOf("name" to "Name", "new" to "Newest",
"old" to "Oldest").forEach { (value, label) ->
PillChip(
selected = order == value,
onClick = { state.setSortOrder(value) },
label = label,
)
}
Spacer(Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
val auto = state.autoTranscribe.collectAsState().value
Checkbox(auto, { state.setAutoTranscribe(it) },
modifier = Modifier.size(28.dp))
Text("Auto-transcribe",
style = MaterialTheme.typography.bodySmall,
color = Shonar.TextSecondary)
}
if (batch == null && remaining > 0 && connected) {
ShonarOutlinedButton({ state.transcribeAll() }) {
Text("Transcribe all ($remaining)")
}
}
}
}
}
/** Page title row for non-library screens is owned by those screens;
* the shell adds only the sidebar divider hairline via Surface. */