From ceb3be3dda4a5eaed5724419f39ceaf61037d894 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 21 Sep 2026 12:35:19 -0500 Subject: [PATCH] Shell: persistent sidebar (views, engine status, theme) + library top bar with search/sort/batch; responsive rail and drawer modes; Ctrl+F search --- .../kotlin/com/shonar/desktop/Components.kt | 4 +- .../main/kotlin/com/shonar/desktop/Main.kt | 58 +++- .../main/kotlin/com/shonar/desktop/Screens.kt | 65 ---- .../main/kotlin/com/shonar/desktop/Shell.kt | 301 ++++++++++++++++++ 4 files changed, 361 insertions(+), 67 deletions(-) create mode 100644 app/src/main/kotlin/com/shonar/desktop/Shell.kt diff --git a/app/src/main/kotlin/com/shonar/desktop/Components.kt b/app/src/main/kotlin/com/shonar/desktop/Components.kt index 321cb2b..4a991d9 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Components.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Components.kt @@ -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, diff --git a/app/src/main/kotlin/com/shonar/desktop/Main.kt b/app/src/main/kotlin/com/shonar/desktop/Main.kt index c26fd56..cdafecd 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Main.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Main.kt @@ -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), + ) + } + } } } } diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index ce06b7d..c5e2e02 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -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)) { diff --git a/app/src/main/kotlin/com/shonar/desktop/Shell.kt b/app/src/main/kotlin/com/shonar/desktop/Shell.kt new file mode 100644 index 0000000..b6120cb --- /dev/null +++ b/app/src/main/kotlin/com/shonar/desktop/Shell.kt @@ -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. */