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:
@@ -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.PinnedNavBarState
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
|
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState
|
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.deck.WorkspaceManager
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
|
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||||
@@ -241,6 +242,44 @@ fun main() {
|
|||||||
},
|
},
|
||||||
onClick = { showComposeDialog = true },
|
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()
|
Separator()
|
||||||
Item(
|
Item(
|
||||||
"Settings",
|
"Settings",
|
||||||
@@ -447,6 +486,10 @@ fun main() {
|
|||||||
key(appRestartKey) {
|
key(appRestartKey) {
|
||||||
App(
|
App(
|
||||||
layoutMode = layoutMode,
|
layoutMode = layoutMode,
|
||||||
|
onLayoutModeChange = { newMode ->
|
||||||
|
layoutMode = newMode
|
||||||
|
DesktopPreferences.layoutMode = newMode.name
|
||||||
|
},
|
||||||
deckState = deckState,
|
deckState = deckState,
|
||||||
workspaceManager = workspaceManager,
|
workspaceManager = workspaceManager,
|
||||||
accountManager = accountManager,
|
accountManager = accountManager,
|
||||||
@@ -479,6 +522,7 @@ fun main() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
layoutMode: LayoutMode,
|
layoutMode: LayoutMode,
|
||||||
|
onLayoutModeChange: (LayoutMode) -> Unit,
|
||||||
deckState: DeckState,
|
deckState: DeckState,
|
||||||
workspaceManager: WorkspaceManager,
|
workspaceManager: WorkspaceManager,
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
@@ -743,7 +787,23 @@ fun App(
|
|||||||
pinnedNavBarState = pinnedNavBarState,
|
pinnedNavBarState = pinnedNavBarState,
|
||||||
workspaceManager = workspaceManager,
|
workspaceManager = workspaceManager,
|
||||||
onSwitchWorkspace = { ws ->
|
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 ->
|
onSelectScreen = { type ->
|
||||||
when (layoutMode) {
|
when (layoutMode) {
|
||||||
|
|||||||
+593
-48
@@ -36,23 +36,40 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Article
|
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.Explore
|
||||||
import androidx.compose.material.icons.filled.Groups
|
import androidx.compose.material.icons.filled.Groups
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.SportsEsports
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
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.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
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.Surface
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TextField
|
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.PointerEventType
|
||||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||||
import androidx.compose.ui.input.pointer.onPointerEvent
|
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.desktop.LayoutMode
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
// -- Screen categories --
|
// -- Screen categories --
|
||||||
@@ -154,12 +173,20 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
|
|||||||
DeckColumnType.Chess,
|
DeckColumnType.Chess,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// -- Tabs --
|
||||||
|
|
||||||
|
enum class AppDrawerTab {
|
||||||
|
SCREENS,
|
||||||
|
WORKSPACES,
|
||||||
|
}
|
||||||
|
|
||||||
// -- State --
|
// -- State --
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
private class AppDrawerState {
|
private class AppDrawerState {
|
||||||
var searchQuery by mutableStateOf("")
|
var searchQuery by mutableStateOf("")
|
||||||
var selectedIndex by mutableStateOf(0)
|
var selectedIndex by mutableStateOf(0)
|
||||||
|
var activeTab by mutableStateOf(AppDrawerTab.SCREENS)
|
||||||
var hashtagInput by mutableStateOf("")
|
var hashtagInput by mutableStateOf("")
|
||||||
var awaitingHashtag by mutableStateOf(false)
|
var awaitingHashtag by mutableStateOf(false)
|
||||||
private var consumed by mutableStateOf(false)
|
private var consumed by mutableStateOf(false)
|
||||||
@@ -179,11 +206,36 @@ private class AppDrawerState {
|
|||||||
filteredScreens.groupBy { it.category() }.filterValues { it.isNotEmpty() }
|
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) {
|
fun moveSelection(delta: Int) {
|
||||||
val size = filteredScreens.size
|
val size = filteredScreens.size
|
||||||
if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1)
|
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(
|
fun select(
|
||||||
screen: DeckColumnType,
|
screen: DeckColumnType,
|
||||||
onSelectScreen: (DeckColumnType) -> Unit,
|
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(
|
fun confirmHashtag(
|
||||||
onSelectScreen: (DeckColumnType) -> Unit,
|
onSelectScreen: (DeckColumnType) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -213,7 +289,7 @@ private class AppDrawerState {
|
|||||||
|
|
||||||
// -- Composables --
|
// -- Composables --
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppDrawer(
|
fun AppDrawer(
|
||||||
openColumnTypes: Set<String>,
|
openColumnTypes: Set<String>,
|
||||||
@@ -225,6 +301,10 @@ fun AppDrawer(
|
|||||||
) {
|
) {
|
||||||
val state = remember { AppDrawerState() }
|
val state = remember { AppDrawerState() }
|
||||||
val searchFocusRequester = remember { FocusRequester() }
|
val searchFocusRequester = remember { FocusRequester() }
|
||||||
|
val allWorkspaces by workspaceManager.workspaces.collectAsState()
|
||||||
|
val filteredWs by remember(allWorkspaces, state.searchQuery) {
|
||||||
|
derivedStateOf { state.filteredWorkspaces(allWorkspaces) }
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(50)
|
delay(50)
|
||||||
@@ -249,12 +329,20 @@ fun AppDrawer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Key.DirectionDown -> {
|
Key.DirectionDown -> {
|
||||||
state.moveSelection(1)
|
if (state.isSearching) {
|
||||||
|
state.moveSelectionUnified(1, filteredWs.size)
|
||||||
|
} else {
|
||||||
|
state.moveSelection(1)
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
Key.DirectionUp -> {
|
Key.DirectionUp -> {
|
||||||
state.moveSelection(-1)
|
if (state.isSearching) {
|
||||||
|
state.moveSelectionUnified(-1, filteredWs.size)
|
||||||
|
} else {
|
||||||
|
state.moveSelection(-1)
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,9 +350,12 @@ fun AppDrawer(
|
|||||||
if (state.awaitingHashtag) {
|
if (state.awaitingHashtag) {
|
||||||
state.confirmHashtag(onSelectScreen, onDismiss)
|
state.confirmHashtag(onSelectScreen, onDismiss)
|
||||||
} else {
|
} else {
|
||||||
state.filteredScreens.getOrNull(state.selectedIndex)?.let {
|
state.selectCurrent(
|
||||||
state.select(it, onSelectScreen, onDismiss)
|
filteredWs,
|
||||||
}
|
onSelectScreen,
|
||||||
|
onSwitchWorkspace,
|
||||||
|
onDismiss,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -302,25 +393,66 @@ fun AppDrawer(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.focusRequester(searchFocusRequester),
|
.focusRequester(searchFocusRequester),
|
||||||
placeholder = { Text("Search screens...") },
|
placeholder = { Text("Search screens and workspaces...") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
leadingIcon = { Icon(Icons.Default.Search, "Search") },
|
leadingIcon = { Icon(Icons.Default.Search, "Search") },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state.awaitingHashtag) {
|
if (state.awaitingHashtag) {
|
||||||
HashtagInputSection(state, onSelectScreen, onDismiss)
|
HashtagInputSection(state, onSelectScreen, onDismiss)
|
||||||
} else {
|
} else if (state.isSearching) {
|
||||||
Box(Modifier.weight(1f)) {
|
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
|
// Context menu for pin/unpin
|
||||||
androidx.compose.material3.DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showMenu,
|
expanded = showMenu,
|
||||||
onDismissRequest = { showMenu = false },
|
onDismissRequest = { showMenu = false },
|
||||||
) {
|
) {
|
||||||
if (PinnedNavBarState.isPinnable(type)) {
|
if (PinnedNavBarState.isPinnable(type)) {
|
||||||
androidx.compose.material3.DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(if (isPinned) "Unpin from sidebar" else "Pin to sidebar") },
|
text = { Text(if (isPinned) "Unpin from sidebar" else "Pin to sidebar") },
|
||||||
onClick = {
|
onClick = {
|
||||||
onTogglePin()
|
onTogglePin()
|
||||||
@@ -544,46 +676,459 @@ private fun HashtagInputSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Workspaces Tab --
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WorkspaceBar(
|
private fun WorkspacesGrid(
|
||||||
workspaceManager: WorkspaceManager,
|
workspaceManager: WorkspaceManager,
|
||||||
onSwitchWorkspace: (Workspace) -> Unit,
|
onSwitchWorkspace: (Workspace) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val workspaces by workspaceManager.workspaces.collectAsState()
|
val workspaces by workspaceManager.workspaces.collectAsState()
|
||||||
val activeIndex by workspaceManager.activeIndex.collectAsState()
|
val activeIndex by workspaceManager.activeIndex.collectAsState()
|
||||||
|
var showEditor by remember { mutableStateOf(false) }
|
||||||
|
var editTarget by remember { mutableStateOf<Workspace?>(null) }
|
||||||
|
|
||||||
androidx.compose.material3.HorizontalDivider()
|
LazyColumn(Modifier.padding(8.dp)) {
|
||||||
|
itemsIndexed(workspaces) { index, ws ->
|
||||||
Row(
|
WorkspaceCard(
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
workspace = ws,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
isActive = index == activeIndex,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
onSwitch = {
|
||||||
) {
|
val switched = workspaceManager.switchTo(index)
|
||||||
Text(
|
if (switched != null) {
|
||||||
"Workspaces",
|
onSwitchWorkspace(switched)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label = { Text(ws.name, style = MaterialTheme.typography.labelSmall) },
|
onEdit = {
|
||||||
leadingIcon = {
|
editTarget = ws
|
||||||
Icon(
|
showEditor = true
|
||||||
WorkspaceIcons.resolve(ws.iconName),
|
|
||||||
contentDescription = ws.name,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
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()
|
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? {
|
private fun parseColumnType(entry: Map<String, Any?>): DeckColumnType? {
|
||||||
val typeKey = entry["type"] as? String ?: return null
|
val typeKey = entry["type"] as? String ?: return null
|
||||||
val param = entry["param"] as? String
|
val param = entry["param"] as? String
|
||||||
|
|||||||
Reference in New Issue
Block a user