feat(desktop): workspace management UX with tabs, editor, unified search

Add two-tab App Drawer (Screens/Workspaces), workspace cards with CRUD,
editor dialog with icon picker and column configuration, unified search
across screens and workspaces, layout mode auto-switching, and
Cmd+Shift+S to save current layout as workspace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-17 14:47:24 +03:00
parent 5b8ddf0a20
commit 09f76f036a
3 changed files with 659 additions and 49 deletions
@@ -96,6 +96,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
import com.vitorpamplona.amethyst.desktop.ui.deck.PinnedNavBarState
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState
import com.vitorpamplona.amethyst.desktop.ui.deck.Workspace
import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
@@ -241,6 +242,44 @@ fun main() {
},
onClick = { showComposeDialog = true },
)
Item(
"Save as Workspace",
shortcut =
if (isMacOS) {
KeyShortcut(Key.S, meta = true, shift = true)
} else {
KeyShortcut(Key.S, ctrl = true, shift = true)
},
onClick = {
if (workspaceManager.workspaces.value.size < WorkspaceManager.MAX_WORKSPACES) {
val columns =
deckState.columns.value.map { col ->
Workspace.WorkspaceColumn(
typeKey = col.type.typeKey(),
param =
when (col.type) {
is DeckColumnType.Hashtag -> col.type.tag
is DeckColumnType.Editor -> col.type.draftSlug
is DeckColumnType.Article -> col.type.addressTag
is DeckColumnType.Profile -> col.type.pubKeyHex
is DeckColumnType.Thread -> col.type.noteId
else -> null
},
width = col.width,
)
}
val ws =
Workspace(
name = "Workspace ${workspaceManager.workspaces.value.size + 1}",
iconName = "Star",
layoutMode = layoutMode,
columns = columns,
)
workspaceManager.addWorkspace(ws)
}
},
enabled = workspaceManager.workspaces.value.size < WorkspaceManager.MAX_WORKSPACES,
)
Separator()
Item(
"Settings",
@@ -447,6 +486,10 @@ fun main() {
key(appRestartKey) {
App(
layoutMode = layoutMode,
onLayoutModeChange = { newMode ->
layoutMode = newMode
DesktopPreferences.layoutMode = newMode.name
},
deckState = deckState,
workspaceManager = workspaceManager,
accountManager = accountManager,
@@ -479,6 +522,7 @@ fun main() {
@Composable
fun App(
layoutMode: LayoutMode,
onLayoutModeChange: (LayoutMode) -> Unit,
deckState: DeckState,
workspaceManager: WorkspaceManager,
accountManager: AccountManager,
@@ -743,7 +787,23 @@ fun App(
pinnedNavBarState = pinnedNavBarState,
workspaceManager = workspaceManager,
onSwitchWorkspace = { ws ->
deckState.loadFromWorkspace(ws.columns)
// Switch layout mode to match workspace
onLayoutModeChange(ws.layoutMode)
// Load columns or single pane screen
when (ws.layoutMode) {
LayoutMode.DECK -> {
deckState.loadFromWorkspace(ws.columns)
}
LayoutMode.SINGLE_PANE -> {
val screenKey =
ws.singlePaneScreen
?: ws.columns.firstOrNull()?.typeKey
?: "home"
val type = DeckState.parseColumnTypeFromKey(screenKey)
if (type != null) singlePaneState.navigate(type)
}
}
},
onSelectScreen = { type ->
when (layoutMode) {
@@ -36,23 +36,40 @@ 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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
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.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
@@ -80,7 +97,9 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.LayoutMode
import kotlinx.coroutines.delay
// -- Screen categories --
@@ -154,12 +173,20 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
DeckColumnType.Chess,
)
// -- Tabs --
enum class AppDrawerTab {
SCREENS,
WORKSPACES,
}
// -- State --
@Stable
private class AppDrawerState {
var searchQuery by mutableStateOf("")
var selectedIndex by mutableStateOf(0)
var activeTab by mutableStateOf(AppDrawerTab.SCREENS)
var hashtagInput by mutableStateOf("")
var awaitingHashtag by mutableStateOf(false)
private var consumed by mutableStateOf(false)
@@ -179,11 +206,36 @@ private class AppDrawerState {
filteredScreens.groupBy { it.category() }.filterValues { it.isNotEmpty() }
}
val isSearching: Boolean by derivedStateOf { searchQuery.isNotBlank() }
fun filteredWorkspaces(allWorkspaces: List<Workspace>): List<Workspace> =
if (searchQuery.isBlank()) {
allWorkspaces
} else {
allWorkspaces.filter { ws ->
ws.name.contains(searchQuery, ignoreCase = true) ||
ws.columns.any { col ->
val displayName =
DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title()
?: col.typeKey
displayName.contains(searchQuery, ignoreCase = true)
}
}
}
fun moveSelection(delta: Int) {
val size = filteredScreens.size
if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1)
}
fun moveSelectionUnified(
delta: Int,
wsCount: Int,
) {
val total = wsCount + filteredScreens.size
if (total > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, total - 1)
}
fun select(
screen: DeckColumnType,
onSelectScreen: (DeckColumnType) -> Unit,
@@ -200,6 +252,30 @@ private class AppDrawerState {
}
}
fun selectCurrent(
filteredWs: List<Workspace>,
onSelectScreen: (DeckColumnType) -> Unit,
onSwitchWorkspace: (Workspace) -> Unit,
onDismiss: () -> Unit,
) {
if (consumed) return
if (!isSearching) {
// Tab-based: only screens use Enter
filteredScreens.getOrNull(selectedIndex)?.let { select(it, onSelectScreen, onDismiss) }
return
}
// Unified: workspaces first, then screens
val wsCount = filteredWs.size
if (selectedIndex < wsCount) {
consumed = true
onSwitchWorkspace(filteredWs[selectedIndex])
onDismiss()
} else {
val screenIdx = selectedIndex - wsCount
filteredScreens.getOrNull(screenIdx)?.let { select(it, onSelectScreen, onDismiss) }
}
}
fun confirmHashtag(
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
@@ -213,7 +289,7 @@ private class AppDrawerState {
// -- Composables --
@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class)
@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun AppDrawer(
openColumnTypes: Set<String>,
@@ -225,6 +301,10 @@ fun AppDrawer(
) {
val state = remember { AppDrawerState() }
val searchFocusRequester = remember { FocusRequester() }
val allWorkspaces by workspaceManager.workspaces.collectAsState()
val filteredWs by remember(allWorkspaces, state.searchQuery) {
derivedStateOf { state.filteredWorkspaces(allWorkspaces) }
}
LaunchedEffect(Unit) {
delay(50)
@@ -249,12 +329,20 @@ fun AppDrawer(
}
Key.DirectionDown -> {
state.moveSelection(1)
if (state.isSearching) {
state.moveSelectionUnified(1, filteredWs.size)
} else {
state.moveSelection(1)
}
true
}
Key.DirectionUp -> {
state.moveSelection(-1)
if (state.isSearching) {
state.moveSelectionUnified(-1, filteredWs.size)
} else {
state.moveSelection(-1)
}
true
}
@@ -262,9 +350,12 @@ fun AppDrawer(
if (state.awaitingHashtag) {
state.confirmHashtag(onSelectScreen, onDismiss)
} else {
state.filteredScreens.getOrNull(state.selectedIndex)?.let {
state.select(it, onSelectScreen, onDismiss)
}
state.selectCurrent(
filteredWs,
onSelectScreen,
onSwitchWorkspace,
onDismiss,
)
}
true
}
@@ -302,25 +393,66 @@ fun AppDrawer(
Modifier
.fillMaxWidth()
.focusRequester(searchFocusRequester),
placeholder = { Text("Search screens...") },
placeholder = { Text("Search screens and workspaces...") },
singleLine = true,
leadingIcon = { Icon(Icons.Default.Search, "Search") },
)
if (state.awaitingHashtag) {
HashtagInputSection(state, onSelectScreen, onDismiss)
} else {
} else if (state.isSearching) {
Box(Modifier.weight(1f)) {
DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss)
UnifiedSearchResults(
state = state,
filteredWs = filteredWs,
workspaceManager = workspaceManager,
onSwitchWorkspace = onSwitchWorkspace,
onSelectScreen = onSelectScreen,
openColumnTypes = openColumnTypes,
pinnedNavBarState = pinnedNavBarState,
onDismiss = onDismiss,
)
}
} else {
// Tab header
PrimaryTabRow(selectedTabIndex = state.activeTab.ordinal) {
AppDrawerTab.entries.forEach { tab ->
Tab(
selected = state.activeTab == tab,
onClick = { state.activeTab = tab },
text = {
Text(
tab.name.lowercase().replaceFirstChar { it.uppercase() },
)
},
)
}
}
Box(Modifier.weight(1f)) {
when (state.activeTab) {
AppDrawerTab.SCREENS -> {
DrawerGrid(
state,
openColumnTypes,
pinnedNavBarState,
onSelectScreen,
onDismiss,
)
}
AppDrawerTab.WORKSPACES -> {
WorkspacesGrid(
workspaceManager = workspaceManager,
onSwitchWorkspace = {
onSwitchWorkspace(it)
onDismiss()
},
onDismiss = onDismiss,
)
}
}
}
// Workspace bar at the bottom
WorkspaceBar(
workspaceManager = workspaceManager,
onSwitchWorkspace = { ws ->
onSwitchWorkspace(ws)
onDismiss()
},
)
}
}
}
@@ -461,12 +593,12 @@ private fun DrawerScreenCard(
}
// Context menu for pin/unpin
androidx.compose.material3.DropdownMenu(
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
if (PinnedNavBarState.isPinnable(type)) {
androidx.compose.material3.DropdownMenuItem(
DropdownMenuItem(
text = { Text(if (isPinned) "Unpin from sidebar" else "Pin to sidebar") },
onClick = {
onTogglePin()
@@ -544,46 +676,459 @@ private fun HashtagInputSection(
}
}
// -- Workspaces Tab --
@Composable
private fun WorkspaceBar(
private fun WorkspacesGrid(
workspaceManager: WorkspaceManager,
onSwitchWorkspace: (Workspace) -> Unit,
onDismiss: () -> Unit,
) {
val workspaces by workspaceManager.workspaces.collectAsState()
val activeIndex by workspaceManager.activeIndex.collectAsState()
var showEditor by remember { mutableStateOf(false) }
var editTarget by remember { mutableStateOf<Workspace?>(null) }
androidx.compose.material3.HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Workspaces",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
workspaces.forEachIndexed { index, ws ->
val isActive = index == activeIndex
androidx.compose.material3.FilterChip(
selected = isActive,
onClick = {
if (!isActive) {
val switched = workspaceManager.switchTo(index)
if (switched != null) onSwitchWorkspace(switched)
LazyColumn(Modifier.padding(8.dp)) {
itemsIndexed(workspaces) { index, ws ->
WorkspaceCard(
workspace = ws,
isActive = index == activeIndex,
onSwitch = {
val switched = workspaceManager.switchTo(index)
if (switched != null) {
onSwitchWorkspace(switched)
}
},
label = { Text(ws.name, style = MaterialTheme.typography.labelSmall) },
leadingIcon = {
Icon(
WorkspaceIcons.resolve(ws.iconName),
contentDescription = ws.name,
modifier = Modifier.size(16.dp),
)
onEdit = {
editTarget = ws
showEditor = true
},
onDelete = { workspaceManager.deleteWorkspace(ws.id) },
canDelete = workspaces.size > 1,
)
}
// "+" card
item {
AddWorkspaceCard(
onClick = {
editTarget = null
showEditor = true
},
enabled = workspaces.size < WorkspaceManager.MAX_WORKSPACES,
)
}
}
if (showEditor) {
WorkspaceEditorDialog(
initial = editTarget,
onSave = { ws ->
if (editTarget != null) {
workspaceManager.updateWorkspace(ws)
} else {
workspaceManager.addWorkspace(ws)
}
showEditor = false
},
onDismiss = { showEditor = false },
)
}
}
@Composable
private fun WorkspaceCard(
workspace: Workspace,
isActive: Boolean,
onSwitch: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
canDelete: Boolean,
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onSwitch),
shape = RoundedCornerShape(12.dp),
tonalElevation = if (isActive) 8.dp else 2.dp,
color =
if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
},
) {
Row(
Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
WorkspaceIcons.resolve(workspace.iconName),
workspace.name,
Modifier.size(28.dp),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(workspace.name, style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
// Layout mode badge — non-interactive Surface+Text
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Text(
if (workspace.layoutMode == LayoutMode.DECK) "Deck" else "Single",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
)
}
}
// Column preview with display names
Text(
workspace.columns.joinToString(", ") { col ->
DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title()
?: col.typeKey
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Active indicator
if (isActive) {
Icon(
Icons.Default.Check,
"Active",
Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = onEdit) {
Icon(Icons.Default.Edit, "Edit", Modifier.size(18.dp))
}
IconButton(onClick = onDelete, enabled = canDelete) {
Icon(Icons.Default.Delete, "Delete", Modifier.size(18.dp))
}
}
}
}
@Composable
private fun AddWorkspaceCard(
onClick: () -> Unit,
enabled: Boolean,
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(enabled = enabled, onClick = onClick),
shape = RoundedCornerShape(12.dp),
tonalElevation = 1.dp,
) {
Row(
Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.Add, "Add workspace", Modifier.size(24.dp))
Spacer(Modifier.width(8.dp))
Text(
"New Workspace",
style = MaterialTheme.typography.titleSmall,
color =
if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
}
}
}
// -- Workspace Editor Dialog --
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun WorkspaceEditorDialog(
initial: Workspace?,
onSave: (Workspace) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember { mutableStateOf(initial?.name ?: "") }
var iconName by remember { mutableStateOf(initial?.iconName ?: "Home") }
var layoutMode by remember { mutableStateOf(initial?.layoutMode ?: LayoutMode.DECK) }
var columns by remember {
mutableStateOf(initial?.columns ?: listOf(Workspace.WorkspaceColumn("home")))
}
var singlePaneScreen by remember { mutableStateOf(initial?.singlePaneScreen) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (initial != null) "Edit Workspace" else "New Workspace") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Icon picker
Text("Icon", style = MaterialTheme.typography.labelMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
WorkspaceIcons.availableNames.forEach { iName ->
IconButton(onClick = { iconName = iName }) {
Icon(
WorkspaceIcons.resolve(iName),
iName,
tint =
if (iName == iconName) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
// Layout mode toggle
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Layout:", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.width(8.dp))
SingleChoiceSegmentedButtonRow {
SegmentedButton(
selected = layoutMode == LayoutMode.SINGLE_PANE,
onClick = { layoutMode = LayoutMode.SINGLE_PANE },
shape = SegmentedButtonDefaults.itemShape(0, 2),
) {
Text("Single Pane")
}
SegmentedButton(
selected = layoutMode == LayoutMode.DECK,
onClick = { layoutMode = LayoutMode.DECK },
shape = SegmentedButtonDefaults.itemShape(1, 2),
) {
Text("Deck")
}
}
}
// Deck: column list
if (layoutMode == LayoutMode.DECK) {
DeckColumnEditor(columns = columns, onColumnsChange = { columns = it })
}
// Single Pane: screen picker
if (layoutMode == LayoutMode.SINGLE_PANE) {
SinglePaneScreenPicker(
selected = singlePaneScreen,
onSelect = { singlePaneScreen = it },
)
}
}
},
confirmButton = {
Button(
onClick = {
onSave(
Workspace(
id =
initial?.id ?: java.util.UUID
.randomUUID()
.toString(),
name = name.ifBlank { "Untitled" },
iconName = iconName,
layoutMode = layoutMode,
columns = columns,
singlePaneScreen = singlePaneScreen,
),
)
},
enabled = name.isNotBlank(),
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun DeckColumnEditor(
columns: List<Workspace.WorkspaceColumn>,
onColumnsChange: (List<Workspace.WorkspaceColumn>) -> Unit,
) {
Column {
Text("Columns", style = MaterialTheme.typography.labelMedium)
columns.forEachIndexed { idx, col ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
DeckState.parseColumnTypeFromKey(col.typeKey, col.param)?.title() ?: col.typeKey,
Modifier.weight(1f),
)
IconButton(
onClick = {
if (columns.size > 1) {
onColumnsChange(columns.toMutableList().apply { removeAt(idx) })
}
},
enabled = columns.size > 1,
) {
Icon(Icons.Default.Close, "Remove")
}
}
}
// Add column dropdown
var expanded by remember { mutableStateOf(false) }
TextButton(onClick = { expanded = true }) { Text("+ Add Column") }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
LAUNCHABLE_SCREENS.filter { !it.requiresInput() }.forEach { screen ->
DropdownMenuItem(
text = { Text(screen.title()) },
onClick = {
onColumnsChange(columns + Workspace.WorkspaceColumn(screen.typeKey()))
expanded = false
},
)
}
}
}
}
@Composable
private fun SinglePaneScreenPicker(
selected: String?,
onSelect: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selectedTitle =
selected?.let { DeckState.parseColumnTypeFromKey(it)?.title() } ?: "Select screen"
Column {
Text("Screen", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
TextButton(onClick = { expanded = true }) { Text(selectedTitle) }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
LAUNCHABLE_SCREENS.filter { !it.requiresInput() }.forEach { screen ->
DropdownMenuItem(
text = { Text(screen.title()) },
onClick = {
onSelect(screen.typeKey())
expanded = false
},
)
}
}
}
}
// -- Unified Search Results --
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun UnifiedSearchResults(
state: AppDrawerState,
filteredWs: List<Workspace>,
workspaceManager: WorkspaceManager,
onSwitchWorkspace: (Workspace) -> Unit,
onSelectScreen: (DeckColumnType) -> Unit,
openColumnTypes: Set<String>,
pinnedNavBarState: PinnedNavBarState,
onDismiss: () -> Unit,
) {
val activeIndex by workspaceManager.activeIndex.collectAsState()
val allWorkspaces by workspaceManager.workspaces.collectAsState()
LazyColumn(Modifier.padding(8.dp)) {
// Workspace results first
if (filteredWs.isNotEmpty()) {
stickyHeader {
Text(
"Workspaces",
style = MaterialTheme.typography.titleSmall,
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
itemsIndexed(filteredWs) { idx, ws ->
val wsIdx = allWorkspaces.indexOf(ws)
val isHighlighted = idx == state.selectedIndex
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
tonalElevation = if (isHighlighted) 8.dp else 0.dp,
) {
WorkspaceCard(
workspace = ws,
isActive = wsIdx == activeIndex,
onSwitch = {
val switched = workspaceManager.switchTo(wsIdx)
if (switched != null) {
onSwitchWorkspace(switched)
onDismiss()
}
},
onEdit = { /* not supported in search results */ },
onDelete = { workspaceManager.deleteWorkspace(ws.id) },
canDelete = allWorkspaces.size > 1,
)
}
}
}
// Screen results below
if (state.filteredScreens.isNotEmpty()) {
stickyHeader {
Text(
"Screens",
style = MaterialTheme.typography.titleSmall,
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
) {
val wsOffset = filteredWs.size
state.filteredScreens.forEachIndexed { localIdx, screen ->
DrawerScreenCard(
type = screen,
isSelected = (wsOffset + localIdx) == state.selectedIndex,
isOpen = openColumnTypes.contains(screen.typeKey()),
isPinned = pinnedNavBarState.isPinned(screen),
onClick = { state.select(screen, onSelectScreen, onDismiss) },
onTogglePin = {
if (pinnedNavBarState.isPinned(screen)) {
pinnedNavBarState.unpin(screen)
} else {
pinnedNavBarState.pin(screen)
}
},
onHover = { state.selectedIndex = wsOffset + localIdx },
)
}
}
}
}
}
}
@@ -287,6 +287,11 @@ class DeckState(
private val mapper = jacksonObjectMapper()
fun parseColumnTypeFromKey(
typeKey: String,
param: String? = null,
): DeckColumnType? = parseColumnType(mapOf("type" to typeKey, "param" to param))
private fun parseColumnType(entry: Map<String, Any?>): DeckColumnType? {
val typeKey = entry["type"] as? String ?: return null
val param = entry["param"] as? String