Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc05af323 | ||
|
|
6eb048575c | ||
|
|
5cdc6135f5 | ||
|
|
f6be34ae48 | ||
|
|
4f06d1fc8f | ||
|
|
d1b8b0abc1 |
@@ -23,6 +23,11 @@ web-typecheck:
|
||||
pnpm -C web install --frozen-lockfile
|
||||
pnpm -C web typecheck
|
||||
|
||||
# Build the Android debug APK and copy it next to the android/ project root.
|
||||
apk:
|
||||
cd android && ./gradlew assembleDebug
|
||||
cp android/app/build/outputs/apk/debug/app-debug.apk android/app-debug.apk
|
||||
|
||||
# Full workspace build + tests.
|
||||
test:
|
||||
cargo fmt --all -- --check
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
.kotlin/
|
||||
/local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
@@ -26,9 +28,21 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusTarget
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isCtrlPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -42,6 +56,8 @@ import pt.naps.maestro.connection.DaemonPrefs
|
||||
import pt.naps.maestro.connection.LinkState
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.data.SessionView
|
||||
import pt.naps.maestro.ui.commandbar.CommandBarSheet
|
||||
import pt.naps.maestro.ui.commandbar.CommandBarViewModel
|
||||
import pt.naps.maestro.ui.sidebar.SidebarContent
|
||||
import pt.naps.maestro.ui.sidebar.sessionName
|
||||
import pt.naps.maestro.ui.spawn.SpawnSheet
|
||||
@@ -60,6 +76,13 @@ private object Routes {
|
||||
fun structured(id: String) = "structured/$id"
|
||||
}
|
||||
|
||||
/** What to pre-fill the spawn form with when it was opened from the command
|
||||
* bar (#270) rather than from a blank "Spawn session" tap. */
|
||||
private sealed interface SpawnSeed {
|
||||
data class Similar(val session: Session) : SpawnSeed
|
||||
data class Directory(val path: String) : SpawnSeed
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level app structure (#268): a persistent [ModalNavigationDrawer] around
|
||||
* a single-back-stack [NavHost], tying together the sidebar (#264/#265), the
|
||||
@@ -77,6 +100,8 @@ fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) {
|
||||
val linkState by daemonLink.state.collectAsState()
|
||||
val baseUrl by prefs.baseUrl.collectAsState()
|
||||
var showSpawnSheet by remember { mutableStateOf(false) }
|
||||
var spawnSeed by remember { mutableStateOf<SpawnSeed?>(null) }
|
||||
var showCommandBar by remember { mutableStateOf(false) }
|
||||
|
||||
val infoClient = remember(baseUrl) { MaestroClient(baseUrl) }
|
||||
var hostname by remember(baseUrl) { mutableStateOf("") }
|
||||
@@ -99,66 +124,144 @@ fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||
SidebarContent(
|
||||
catalogStore = daemonLink.catalog,
|
||||
onOpenSession = ::openSession,
|
||||
)
|
||||
}
|
||||
},
|
||||
fun spawnNew() {
|
||||
spawnSeed = null
|
||||
showSpawnSheet = true
|
||||
}
|
||||
|
||||
fun spawnInDirectory(path: String) {
|
||||
spawnSeed = SpawnSeed.Directory(path)
|
||||
showSpawnSheet = true
|
||||
}
|
||||
|
||||
fun spawnSimilar(session: Session) {
|
||||
spawnSeed = SpawnSeed.Similar(session)
|
||||
showSpawnSheet = true
|
||||
}
|
||||
|
||||
// Ctrl-K opens the bar from anywhere (docs/COMMAND-BAR.md); a focusable
|
||||
// root keeps the shortcut live except while something else (the bar's own
|
||||
// search field, a text input) holds focus instead.
|
||||
val rootFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(showCommandBar) {
|
||||
if (!showCommandBar) rootFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(rootFocusRequester)
|
||||
.focusTarget()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.isCtrlPressed && event.key == Key.K) {
|
||||
showCommandBar = true
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
ConnectionStatusBar(linkState = linkState, hostname = hostname)
|
||||
NavHost(navController = navController, startDestination = Routes.WELCOME) {
|
||||
composable(Routes.WELCOME) {
|
||||
WelcomeScreen(
|
||||
onOpenSessions = { scope.launch { drawerState.open() } },
|
||||
onSpawnSession = { showSpawnSheet = true },
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||
SidebarContent(
|
||||
catalogStore = daemonLink.catalog,
|
||||
onOpenSession = ::openSession,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Routes.TERMINAL,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
|
||||
) { backStackEntry ->
|
||||
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
|
||||
TerminalScreen(
|
||||
daemonBaseUrl = baseUrl,
|
||||
sessionId = id,
|
||||
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
|
||||
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Routes.STRUCTURED,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
|
||||
) { backStackEntry ->
|
||||
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
|
||||
StructuredScreen(
|
||||
daemonBaseUrl = baseUrl,
|
||||
sessionId = id,
|
||||
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
|
||||
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
ConnectionStatusBar(linkState = linkState, hostname = hostname)
|
||||
NavHost(navController = navController, startDestination = Routes.WELCOME) {
|
||||
composable(Routes.WELCOME) {
|
||||
WelcomeScreen(
|
||||
onOpenSessions = { scope.launch { drawerState.open() } },
|
||||
onSpawnSession = { spawnNew() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Routes.TERMINAL,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
|
||||
) { backStackEntry ->
|
||||
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
|
||||
TerminalScreen(
|
||||
daemonBaseUrl = baseUrl,
|
||||
sessionId = id,
|
||||
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
|
||||
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Routes.STRUCTURED,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
|
||||
) { backStackEntry ->
|
||||
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
|
||||
StructuredScreen(
|
||||
daemonBaseUrl = baseUrl,
|
||||
sessionId = id,
|
||||
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
|
||||
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = { showCommandBar = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp)
|
||||
.semantics { contentDescription = "Search sessions and commands" },
|
||||
) {
|
||||
Text("⌕")
|
||||
}
|
||||
}
|
||||
|
||||
if (showSpawnSheet) {
|
||||
val viewModel = remember(infoClient) { SpawnViewModel(infoClient) }
|
||||
val seed = spawnSeed
|
||||
val viewModel = remember(infoClient, seed) {
|
||||
SpawnViewModel(infoClient).also {
|
||||
when (seed) {
|
||||
is SpawnSeed.Similar -> {
|
||||
it.setCwd(seed.session.cwd)
|
||||
it.setCustomCommand(seed.session.command)
|
||||
}
|
||||
is SpawnSeed.Directory -> it.setCwd(seed.path)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
SpawnSheet(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { showSpawnSheet = false },
|
||||
onDismiss = {
|
||||
showSpawnSheet = false
|
||||
spawnSeed = null
|
||||
},
|
||||
onSpawned = { session ->
|
||||
showSpawnSheet = false
|
||||
spawnSeed = null
|
||||
openSession(session)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showCommandBar) {
|
||||
val commandBarViewModel = remember(daemonLink, infoClient) {
|
||||
CommandBarViewModel(daemonLink.catalog, infoClient)
|
||||
}
|
||||
CommandBarSheet(
|
||||
viewModel = commandBarViewModel,
|
||||
onDismiss = { showCommandBar = false },
|
||||
onOpenSession = ::openSession,
|
||||
onSpawnNew = ::spawnNew,
|
||||
onSpawnDirectory = ::spawnInDirectory,
|
||||
onSpawnSimilar = ::spawnSimilar,
|
||||
)
|
||||
}
|
||||
|
||||
// Declared last so it sits above the NavHost's own back handling on the
|
||||
// dispatcher stack: a drawer open over a session must close first, not
|
||||
// pop the session back to Welcome.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package pt.naps.maestro.ui.commandbar
|
||||
|
||||
import java.time.Instant
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.data.SessionStatus
|
||||
import pt.naps.maestro.ui.sidebar.programName
|
||||
import pt.naps.maestro.ui.sidebar.sessionName
|
||||
|
||||
/** The command bar's pure model (docs/COMMAND-BAR.md): candidates, the sigil
|
||||
* grammar, and the ranking. Android's v1 matcher is deliberately a simple
|
||||
* weighted substring/fuzzy scorer, not the scored dynamic-program matcher
|
||||
* `web`/#146 and the TUI/#147 share — see the issue for why a lighter
|
||||
* matcher was chosen for the first cut. */
|
||||
sealed interface BarCandidate {
|
||||
data class SessionItem(val session: Session) : BarCandidate
|
||||
data class CommandItem(val command: BarCommand) : BarCandidate
|
||||
data class DirectoryItem(val path: String, val display: String) : BarCandidate
|
||||
}
|
||||
|
||||
data class BarCommand(val id: String, val label: String)
|
||||
|
||||
/** The bar's fixed `>` registry for v1: the app has no keymap/command palette
|
||||
* of its own yet, so spawning is the one action worth reaching from anywhere. */
|
||||
val BAR_COMMANDS: List<BarCommand> = listOf(BarCommand(id = "spawn", label = "Spawn session"))
|
||||
|
||||
sealed interface BarSigil {
|
||||
object Commands : BarSigil
|
||||
object Directories : BarSigil
|
||||
}
|
||||
|
||||
data class ParsedBarQuery(val sigil: BarSigil?, val query: String)
|
||||
|
||||
/** A sigil is only a sigil in first position (docs/COMMAND-BAR.md) — typed
|
||||
* anywhere else, or backspaced away, it is an ordinary query character. */
|
||||
fun parseBarQuery(raw: String): ParsedBarQuery = when (raw.firstOrNull()) {
|
||||
'>' -> ParsedBarQuery(BarSigil.Commands, raw.substring(1))
|
||||
'~' -> ParsedBarQuery(BarSigil.Directories, raw.substring(1))
|
||||
else -> ParsedBarQuery(null, raw)
|
||||
}
|
||||
|
||||
private fun instantMs(iso: String?): Long? {
|
||||
if (iso.isNullOrEmpty()) return null
|
||||
return runCatching { Instant.parse(iso).toEpochMilli() }.getOrNull()
|
||||
}
|
||||
|
||||
/** Empty-query session order (docs/COMMAND-BAR.md, "Empty query"): most
|
||||
* recently focused first; a session never focused sorts after every session
|
||||
* that has been, most recently created first among those; a dead session
|
||||
* sorts last regardless of recency. */
|
||||
fun orderSessionsForBar(sessions: List<Session>): List<Session> = sessions.sortedWith(
|
||||
Comparator { a, b ->
|
||||
val deadA = if (a.status == SessionStatus.Dead) 1 else 0
|
||||
val deadB = if (b.status == SessionStatus.Dead) 1 else 0
|
||||
if (deadA != deadB) return@Comparator deadA - deadB
|
||||
|
||||
val focusA = instantMs(a.last_focused_at)
|
||||
val focusB = instantMs(b.last_focused_at)
|
||||
if ((focusA == null) != (focusB == null)) return@Comparator if (focusA == null) 1 else -1
|
||||
if (focusA != null && focusB != null && focusA != focusB) return@Comparator focusB.compareTo(focusA)
|
||||
|
||||
(instantMs(b.created_at) ?: 0L).compareTo(instantMs(a.created_at) ?: 0L)
|
||||
},
|
||||
)
|
||||
|
||||
/** `~/dev/api`, not `/home/me/dev/api` — the abbreviation the sidebar and the
|
||||
* spawn form both use. */
|
||||
fun abbreviateCwd(path: String, homeDir: String): String = when {
|
||||
homeDir.isEmpty() -> path
|
||||
path == homeDir -> "~"
|
||||
path.startsWith("$homeDir/") -> "~" + path.removePrefix(homeDir)
|
||||
else -> path
|
||||
}
|
||||
|
||||
/** The inverse of [abbreviateCwd], for a path the operator typed. */
|
||||
fun expandHome(path: String, homeDir: String): String = when {
|
||||
homeDir.isEmpty() -> path
|
||||
path == "~" -> homeDir
|
||||
path.startsWith("~/") -> homeDir + path.removePrefix("~")
|
||||
else -> path
|
||||
}
|
||||
|
||||
/** `~`'s candidates, deduped by the directory they spawn in and keeping the
|
||||
* first mention of each. */
|
||||
fun directoryCandidates(paths: List<String>, homeDir: String): List<BarCandidate.DirectoryItem> {
|
||||
val seen = LinkedHashSet<String>()
|
||||
val out = mutableListOf<BarCandidate.DirectoryItem>()
|
||||
for (path in paths) {
|
||||
if (path.isEmpty() || !seen.add(path)) continue
|
||||
out += BarCandidate.DirectoryItem(path = path, display = abbreviateCwd(path, homeDir))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private data class BarField(val name: String, val text: String, val weight: Int)
|
||||
|
||||
/** Field weights, ported from docs/COMMAND-BAR.md's candidate table — kept so
|
||||
* an exact `tool` match can still outrank a weaker `name` match, even though
|
||||
* the per-character scoring underneath is simplified. */
|
||||
private fun fieldsFor(candidate: BarCandidate, homeDir: String): List<BarField> = when (candidate) {
|
||||
is BarCandidate.SessionItem -> {
|
||||
val session = candidate.session
|
||||
val directory = session.worktree?.base_repo ?: session.cwd
|
||||
listOfNotNull(
|
||||
BarField("name", sessionName(session), 0),
|
||||
BarField("directory", abbreviateCwd(directory, homeDir), -24),
|
||||
session.command.takeIf { it.isNotEmpty() }?.let { BarField("command", it, -32) },
|
||||
BarField("tool", programName(session.command), -40),
|
||||
)
|
||||
}
|
||||
is BarCandidate.CommandItem -> listOf(BarField("label", candidate.command.label, 0))
|
||||
is BarCandidate.DirectoryItem -> listOf(BarField("path", candidate.display, 0))
|
||||
}
|
||||
|
||||
private const val MATCH_EXACT = 100
|
||||
private const val MATCH_PREFIX = 70
|
||||
private const val MATCH_CONTAINS = 50
|
||||
private const val MATCH_FUZZY = 20
|
||||
|
||||
/** Case-insensitive subsequence: every character of [query] appears in [text]
|
||||
* in order, not necessarily adjacent. */
|
||||
private fun isSubsequence(query: String, text: String): Boolean {
|
||||
var i = 0
|
||||
for (c in text) {
|
||||
if (i >= query.length) break
|
||||
if (c == query[i]) i++
|
||||
}
|
||||
return i == query.length
|
||||
}
|
||||
|
||||
private fun fieldMatchScore(query: String, text: String): Int? {
|
||||
val q = query.lowercase()
|
||||
val t = text.lowercase()
|
||||
return when {
|
||||
t == q -> MATCH_EXACT
|
||||
t.startsWith(q) -> MATCH_PREFIX
|
||||
t.contains(q) -> MATCH_CONTAINS
|
||||
isSubsequence(q, t) -> MATCH_FUZZY
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun kindOrder(candidate: BarCandidate): Int = when (candidate) {
|
||||
is BarCandidate.SessionItem -> 0
|
||||
is BarCandidate.CommandItem -> 1
|
||||
is BarCandidate.DirectoryItem -> 2
|
||||
}
|
||||
|
||||
data class BarResult(val candidate: BarCandidate, val score: Int, val matchedField: String?)
|
||||
|
||||
/** Ranks [candidates] against [query]. An empty query matches everything at
|
||||
* score 0 in input order (docs/COMMAND-BAR.md, "Empty query"); the bar's own
|
||||
* rules decide what is in the candidate list at that point. */
|
||||
fun rankCandidates(query: String, candidates: List<BarCandidate>, homeDir: String): List<BarResult> {
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.isEmpty()) return candidates.map { BarResult(it, 0, null) }
|
||||
|
||||
val results = candidates.mapNotNull { candidate ->
|
||||
var best: BarResult? = null
|
||||
for (field in fieldsFor(candidate, homeDir)) {
|
||||
val matchScore = fieldMatchScore(trimmed, field.text) ?: continue
|
||||
val total = matchScore + field.weight
|
||||
if (best == null || total > best.score) best = BarResult(candidate, total, field.name)
|
||||
}
|
||||
best
|
||||
}
|
||||
// Stable sort: ties keep the caller's order (sessions most-recently-focused
|
||||
// first, commands in registry order), matching the shared matcher's last
|
||||
// tie-break without reproducing its length/codepoint rules.
|
||||
return results.sortedWith(compareByDescending<BarResult> { it.score }.thenBy { kindOrder(it.candidate) })
|
||||
}
|
||||
|
||||
data class BarGroup(val title: String, val results: List<BarResult>)
|
||||
|
||||
private fun titleFor(candidate: BarCandidate): String = when (candidate) {
|
||||
is BarCandidate.SessionItem -> "Sessions"
|
||||
is BarCandidate.CommandItem -> "Commands"
|
||||
is BarCandidate.DirectoryItem -> "Directories"
|
||||
}
|
||||
|
||||
/** Buckets a ranked list by kind for rendering. Grouping is a rendering of the
|
||||
* ranking, never a reordering of it: groups appear in the order their
|
||||
* best-scoring member does, because [results] is already rank-ordered and a
|
||||
* `LinkedHashMap` keeps first-seen order. */
|
||||
fun groupResults(results: List<BarResult>): List<BarGroup> {
|
||||
val groups = LinkedHashMap<String, MutableList<BarResult>>()
|
||||
for (result in results) {
|
||||
groups.getOrPut(titleFor(result.candidate)) { mutableListOf() }.add(result)
|
||||
}
|
||||
return groups.map { (title, group) -> BarGroup(title, group) }
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package pt.naps.maestro.ui.commandbar
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.ui.sidebar.ActivityRail
|
||||
import pt.naps.maestro.ui.sidebar.MetaLine
|
||||
import pt.naps.maestro.ui.sidebar.customSessionName
|
||||
import pt.naps.maestro.ui.sidebar.formatInTheWay
|
||||
import pt.naps.maestro.ui.sidebar.rememberTickingNowMs
|
||||
import pt.naps.maestro.ui.sidebar.sessionBlockers
|
||||
import pt.naps.maestro.ui.sidebar.sessionName
|
||||
|
||||
/** #270's `ctrl-k` command bar (docs/COMMAND-BAR.md): a search field over a
|
||||
* bottom sheet, sessions and the `>` registry mixed by default, `>` and `~`
|
||||
* narrowing to one kind. Tap opens a result; long-press on a session opens
|
||||
* its action menu (rename, kill, spawn similar) — the bar picks the action
|
||||
* and hands off to the flow that already implements it, it never
|
||||
* reimplements one. */
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CommandBarSheet(
|
||||
viewModel: CommandBarViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onOpenSession: (Session) -> Unit,
|
||||
onSpawnNew: () -> Unit,
|
||||
onSpawnDirectory: (String) -> Unit,
|
||||
onSpawnSimilar: (Session) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val nowMs = rememberTickingNowMs()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
var actionSession by remember { mutableStateOf<Session?>(null) }
|
||||
var renaming by remember { mutableStateOf<Session?>(null) }
|
||||
var confirmingKill by remember { mutableStateOf<Session?>(null) }
|
||||
|
||||
fun activate(candidate: BarCandidate) {
|
||||
when (candidate) {
|
||||
is BarCandidate.SessionItem -> onOpenSession(candidate.session)
|
||||
is BarCandidate.CommandItem -> onSpawnNew()
|
||||
is BarCandidate.DirectoryItem -> onSpawnDirectory(candidate.path)
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||
Column(Modifier.fillMaxWidth().heightIn(max = 560.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.query,
|
||||
onValueChange = viewModel::onQueryChange,
|
||||
placeholder = { Text(placeholderFor(state.sigil)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
if (state.groups.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"No matches",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val showHeaders = state.sigil == null
|
||||
LazyColumn(Modifier.fillMaxWidth()) {
|
||||
for (group in state.groups) {
|
||||
if (showHeaders) {
|
||||
item(key = "header:${group.title}") {
|
||||
Text(
|
||||
text = group.title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(group.results, key = { rowKey(it.candidate) }) { result ->
|
||||
BarRow(
|
||||
candidate = result.candidate,
|
||||
nowMs = nowMs,
|
||||
onClick = { activate(result.candidate) },
|
||||
onLongClick = {
|
||||
val session = (result.candidate as? BarCandidate.SessionItem)?.session
|
||||
if (session != null) actionSession = session
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actionSession?.let { session ->
|
||||
SessionActionMenu(
|
||||
session = session,
|
||||
onDismiss = { actionSession = null },
|
||||
onRename = {
|
||||
actionSession = null
|
||||
renaming = session
|
||||
},
|
||||
onKill = {
|
||||
actionSession = null
|
||||
confirmingKill = session
|
||||
},
|
||||
onSpawnSimilar = {
|
||||
actionSession = null
|
||||
onDismiss()
|
||||
onSpawnSimilar(session)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
renaming?.let { session ->
|
||||
RenameDialog(
|
||||
initial = customSessionName(session) ?: "",
|
||||
onCommit = {
|
||||
renaming = null
|
||||
viewModel.rename(session, it)
|
||||
},
|
||||
onCancel = { renaming = null },
|
||||
)
|
||||
}
|
||||
|
||||
confirmingKill?.let { session ->
|
||||
KillConfirmDialog(
|
||||
session = session,
|
||||
onConfirm = {
|
||||
confirmingKill = null
|
||||
viewModel.kill(session)
|
||||
},
|
||||
onCancel = { confirmingKill = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun placeholderFor(sigil: BarSigil?): String = when (sigil) {
|
||||
BarSigil.Commands -> "Run a command…"
|
||||
BarSigil.Directories -> "Spawn in a directory…"
|
||||
null -> "Go to a session…"
|
||||
}
|
||||
|
||||
private fun rowKey(candidate: BarCandidate): String = when (candidate) {
|
||||
is BarCandidate.SessionItem -> "session:${candidate.session.id}"
|
||||
is BarCandidate.CommandItem -> "command:${candidate.command.id}"
|
||||
is BarCandidate.DirectoryItem -> "directory:${candidate.path}"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun BarRow(
|
||||
candidate: BarCandidate,
|
||||
nowMs: Long,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (candidate) {
|
||||
is BarCandidate.SessionItem -> {
|
||||
ActivityRail(activity = candidate.session.activity, modifier = Modifier.height(36.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = sessionName(candidate.session),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
MetaLine(session = candidate.session, nowMs = nowMs)
|
||||
}
|
||||
}
|
||||
is BarCandidate.CommandItem -> {
|
||||
Text(
|
||||
text = candidate.command.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
is BarCandidate.DirectoryItem -> {
|
||||
Text(
|
||||
text = candidate.display,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Long-press's action menu (docs/COMMAND-BAR.md, "Drill-in"), collapsed from
|
||||
* the desktop clients' fuzzy-searchable action page to a plain dialog — a
|
||||
* touch long-press already named the one session being acted on, so there is
|
||||
* nothing left to search. */
|
||||
@Composable
|
||||
private fun SessionActionMenu(
|
||||
session: Session,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onKill: () -> Unit,
|
||||
onSpawnSimilar: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(sessionName(session)) },
|
||||
text = {
|
||||
Column {
|
||||
TextButton(onClick = onRename, modifier = Modifier.fillMaxWidth()) { Text("Rename") }
|
||||
TextButton(onClick = onSpawnSimilar, modifier = Modifier.fillMaxWidth()) { Text("Spawn similar") }
|
||||
TextButton(onClick = onKill, modifier = Modifier.fillMaxWidth()) { Text("Kill") }
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
|
||||
var text by remember { mutableStateOf(initial) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text("Rename session") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
||||
val blockers = sessionBlockers(session)
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text("Kill “${sessionName(session)}”?") },
|
||||
text = {
|
||||
Text(
|
||||
if (blockers != null) {
|
||||
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
|
||||
} else {
|
||||
"This ends the session's process. This can't be undone."
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package pt.naps.maestro.ui.commandbar
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import pt.naps.maestro.api.MaestroClient
|
||||
import pt.naps.maestro.connection.CatalogStore
|
||||
import pt.naps.maestro.data.Session
|
||||
|
||||
/** Quiet period before a typed `~` path is probed over `GET /api/dirs`,
|
||||
* mirroring the spawn form's own autocomplete debounce. */
|
||||
private const val PROBE_DEBOUNCE_MS = 250L
|
||||
|
||||
data class CommandBarUiState(
|
||||
val query: String = "",
|
||||
val sigil: BarSigil? = null,
|
||||
val groups: List<BarGroup> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Search logic behind [pt.naps.maestro.ui.commandbar.CommandBarSheet]
|
||||
* (#270): folds the live session catalog, the fixed `>` command registry and
|
||||
* the `~` directory list into one ranked, grouped result set as the query
|
||||
* changes. A plain class, not an `androidx.lifecycle.ViewModel` — same shape
|
||||
* as [pt.naps.maestro.ui.spawn.SpawnViewModel], which this otherwise mirrors.
|
||||
*/
|
||||
class CommandBarViewModel(
|
||||
private val catalogStore: CatalogStore,
|
||||
private val client: MaestroClient,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
private val _state = MutableStateFlow(CommandBarUiState())
|
||||
val state: StateFlow<CommandBarUiState> = _state.asStateFlow()
|
||||
|
||||
private var homeDir: String = ""
|
||||
private var recentDirectories: List<BarCandidate.DirectoryItem> = emptyList()
|
||||
private var probedDirectories: List<BarCandidate.DirectoryItem> = emptyList()
|
||||
private var recentDirectoriesLoaded = false
|
||||
private var probeJob: Job? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
homeDir = runCatching { client.getDaemonInfo().home_dir }.getOrDefault("")
|
||||
recompute()
|
||||
}
|
||||
scope.launch {
|
||||
catalogStore.sessions.collect { recompute() }
|
||||
}
|
||||
}
|
||||
|
||||
fun onQueryChange(raw: String) {
|
||||
_state.update { it.copy(query = raw, sigil = parseBarQuery(raw).sigil) }
|
||||
val parsed = parseBarQuery(raw)
|
||||
if (parsed.sigil is BarSigil.Directories) {
|
||||
ensureRecentDirectoriesLoaded()
|
||||
probeTypedDirectory(parsed.query)
|
||||
}
|
||||
recompute()
|
||||
}
|
||||
|
||||
/** `PATCH /api/sessions/:id`. Fire-and-forget: the catalog reflects the
|
||||
* rename over the events socket like any other client's edit. */
|
||||
fun rename(session: Session, name: String) {
|
||||
scope.launch { runCatching { client.renameSession(session.id, name) } }
|
||||
}
|
||||
|
||||
/** `DELETE /api/sessions/:id`, unforced — the same default the sidebar's
|
||||
* own kill confirmation uses. */
|
||||
fun kill(session: Session) {
|
||||
scope.launch { runCatching { client.killSession(session.id) } }
|
||||
}
|
||||
|
||||
private fun ensureRecentDirectoriesLoaded() {
|
||||
if (recentDirectoriesLoaded) return
|
||||
recentDirectoriesLoaded = true
|
||||
scope.launch {
|
||||
val recents = runCatching { client.recentDirs().dirs.map { it.cwd } }.getOrDefault(emptyList())
|
||||
val projects = runCatching { client.listProjects().map { it.path } }.getOrDefault(emptyList())
|
||||
recentDirectories = directoryCandidates(recents + projects, homeDir)
|
||||
recompute()
|
||||
}
|
||||
}
|
||||
|
||||
/** Fills the `~` list in as a path is typed, the way the spawn form's own
|
||||
* autocomplete does: the directory named so far is listed and its
|
||||
* children join the candidates. */
|
||||
private fun probeTypedDirectory(query: String) {
|
||||
val slash = query.lastIndexOf('/')
|
||||
if (slash < 0) {
|
||||
probedDirectories = emptyList()
|
||||
probeJob?.cancel()
|
||||
return
|
||||
}
|
||||
val dir = expandHome(query.substring(0, slash + 1), homeDir)
|
||||
probeJob?.cancel()
|
||||
probeJob = scope.launch {
|
||||
delay(PROBE_DEBOUNCE_MS)
|
||||
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
|
||||
probedDirectories = directoryCandidates(entries.map { "$dir$it" }, homeDir)
|
||||
recompute()
|
||||
}
|
||||
}
|
||||
|
||||
private fun directoryListCandidates(): List<BarCandidate> {
|
||||
val seen = LinkedHashSet<String>()
|
||||
val out = mutableListOf<BarCandidate.DirectoryItem>()
|
||||
for (candidate in recentDirectories + probedDirectories) {
|
||||
if (seen.add(candidate.path)) out += candidate
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun recompute() {
|
||||
val raw = _state.value.query
|
||||
val parsed = parseBarQuery(raw)
|
||||
val candidates: List<BarCandidate> = when (parsed.sigil) {
|
||||
is BarSigil.Commands -> BAR_COMMANDS.map { BarCandidate.CommandItem(it) }
|
||||
is BarSigil.Directories -> directoryListCandidates()
|
||||
null -> {
|
||||
val sessions = orderSessionsForBar(catalogStore.sessions.value.values.toList())
|
||||
.map { BarCandidate.SessionItem(it) }
|
||||
if (parsed.query.isEmpty()) sessions else sessions + BAR_COMMANDS.map { BarCandidate.CommandItem(it) }
|
||||
}
|
||||
}
|
||||
val ranked = rankCandidates(parsed.query, candidates, homeDir)
|
||||
_state.update { it.copy(groups = groupResults(ranked)) }
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ package pt.naps.maestro.ui.sidebar
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.InfiniteTransition
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -52,18 +52,51 @@ fun activityStateWord(activity: ActivityState): String =
|
||||
ActivityState.Error -> "Error"
|
||||
}
|
||||
|
||||
/** A held bar that pulses its alpha between 1 and [minAlpha] every
|
||||
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
|
||||
/** A hard on/off blink, one cycle per 1200ms — mirrors the web rail's
|
||||
* `maestro-rail-blink` keyframes (docs/SIDEBAR.md, web/src/components/sidebar/sidebar.css):
|
||||
* held at full alpha for the first half of the cycle, then snapped to 0 for
|
||||
* the second half, with no crossfade. */
|
||||
@Composable
|
||||
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
|
||||
private fun InfiniteTransition.blinkAlpha(): Float {
|
||||
val alpha by animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = minAlpha,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = periodMs, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
animation = keyframes {
|
||||
durationMillis = 1200
|
||||
1f at 0
|
||||
1f at 599
|
||||
0f at 600
|
||||
0f at 1199
|
||||
},
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "activity-rail-alpha",
|
||||
label = "activity-rail-blink",
|
||||
)
|
||||
return alpha
|
||||
}
|
||||
|
||||
/** A fast double-flash every 1400ms — mirrors the web rail's
|
||||
* `maestro-rail-flash` keyframes: two dips to quarter alpha early in the
|
||||
* cycle, then held at full alpha for the remainder. */
|
||||
@Composable
|
||||
private fun InfiniteTransition.flashAlpha(): Float {
|
||||
val alpha by animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1400
|
||||
1f at 0 using LinearOutSlowInEasing
|
||||
0.25f at 210 using LinearOutSlowInEasing
|
||||
1f at 420 using LinearOutSlowInEasing
|
||||
0.25f at 630 using LinearOutSlowInEasing
|
||||
1f at 840 using LinearOutSlowInEasing
|
||||
1f at 1400
|
||||
},
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "activity-rail-flash",
|
||||
)
|
||||
return alpha
|
||||
}
|
||||
@@ -77,8 +110,8 @@ fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
|
||||
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
|
||||
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
|
||||
val alpha = when (activity) {
|
||||
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
|
||||
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
|
||||
ActivityState.AwaitingInput -> infinite.blinkAlpha()
|
||||
ActivityState.Error -> infinite.flashAlpha()
|
||||
else -> 1f
|
||||
}
|
||||
Box(
|
||||
|
||||
@@ -2,10 +2,45 @@ package pt.naps.maestro.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
/** Palette translated from the web SPA's OKLCH design tokens
|
||||
* (web/src/styles.css) into sRGB. Dark is the default theme; Light is opt-in.
|
||||
* Semantic mapping (see docs referenced from issue #271):
|
||||
* bg -> background, surface* -> surface layers, ink -> onBackground/onSurface,
|
||||
* muted -> onSurfaceVariant, faint -> outline, accent -> primary,
|
||||
* live -> tertiary, danger -> error, warn -> secondary. */
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
// ---- Dark theme (default) ----
|
||||
val DarkBg = Color(0xFF0E1012)
|
||||
val DarkSurface = Color(0xFF16181B)
|
||||
val DarkSurface2 = Color(0xFF1E2125)
|
||||
val DarkSurface3 = Color(0xFF272B2F)
|
||||
val DarkBorder = Color(0xFF2E3136)
|
||||
val DarkBorderStrong = Color(0xFF42474D)
|
||||
val DarkInk = Color(0xFFEBEDEF)
|
||||
val DarkMuted = Color(0xFFA1A5AA)
|
||||
val DarkFaint = Color(0xFF777B7F)
|
||||
val DarkAccent = Color(0xFFE2E5E8)
|
||||
val DarkAccentStrong = Color(0xFFF4F5F7)
|
||||
val DarkAccentInk = Color(0xFF13161C)
|
||||
val DarkLive = Color(0xFF65EA92)
|
||||
val DarkDanger = Color(0xFFFF6F69)
|
||||
val DarkWarn = Color(0xFFEEBC4A)
|
||||
val DarkTermBg = Color(0xFF0B0C0F)
|
||||
|
||||
// ---- Light theme ----
|
||||
val LightBg = Color(0xFFF9FAFB)
|
||||
val LightSurface = Color(0xFFFFFFFF)
|
||||
val LightSurface2 = Color(0xFFF2F3F5)
|
||||
val LightSurface3 = Color(0xFFE6E8EA)
|
||||
val LightBorder = Color(0xFFD9DBDF)
|
||||
val LightBorderStrong = Color(0xFFC1C4C9)
|
||||
val LightInk = Color(0xFF20242A)
|
||||
val LightMuted = Color(0xFF565B62)
|
||||
val LightFaint = Color(0xFF81878D)
|
||||
val LightAccent = Color(0xFF23272B)
|
||||
val LightAccentStrong = Color(0xFF13161B)
|
||||
val LightAccentInk = Color(0xFFF9FAFB)
|
||||
val LightLive = Color(0xFF069A4A)
|
||||
val LightDanger = Color(0xFFD01D21)
|
||||
val LightWarn = Color(0xFFC78200)
|
||||
val LightTermBg = Color(0xFF0B0F14)
|
||||
|
||||
@@ -10,18 +10,71 @@ import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/** Dark scheme (default), translated from the web SPA's dark token set
|
||||
* (web/src/styles.css) — see Color.kt for the token -> role mapping. */
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80,
|
||||
background = DarkBg,
|
||||
onBackground = DarkInk,
|
||||
surface = DarkSurface,
|
||||
onSurface = DarkInk,
|
||||
surfaceVariant = DarkSurface2,
|
||||
onSurfaceVariant = DarkMuted,
|
||||
outline = DarkFaint,
|
||||
outlineVariant = DarkBorder,
|
||||
surfaceTint = DarkAccent,
|
||||
primary = DarkAccent,
|
||||
onPrimary = DarkAccentInk,
|
||||
primaryContainer = DarkSurface3,
|
||||
onPrimaryContainer = DarkInk,
|
||||
secondary = DarkWarn,
|
||||
onSecondary = DarkAccentInk,
|
||||
secondaryContainer = DarkSurface3,
|
||||
onSecondaryContainer = DarkInk,
|
||||
tertiary = DarkLive,
|
||||
onTertiary = DarkAccentInk,
|
||||
tertiaryContainer = DarkSurface3,
|
||||
onTertiaryContainer = DarkInk,
|
||||
error = DarkDanger,
|
||||
onError = DarkAccentInk,
|
||||
errorContainer = DarkSurface3,
|
||||
onErrorContainer = DarkDanger,
|
||||
)
|
||||
|
||||
/** Light scheme, opt-in. */
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40,
|
||||
background = LightBg,
|
||||
onBackground = LightInk,
|
||||
surface = LightSurface,
|
||||
onSurface = LightInk,
|
||||
surfaceVariant = LightSurface2,
|
||||
onSurfaceVariant = LightMuted,
|
||||
outline = LightFaint,
|
||||
outlineVariant = LightBorder,
|
||||
surfaceTint = LightAccent,
|
||||
primary = LightAccent,
|
||||
onPrimary = LightAccentInk,
|
||||
primaryContainer = LightSurface3,
|
||||
onPrimaryContainer = LightInk,
|
||||
secondary = LightWarn,
|
||||
onSecondary = LightAccentInk,
|
||||
secondaryContainer = LightSurface3,
|
||||
onSecondaryContainer = LightInk,
|
||||
tertiary = LightLive,
|
||||
onTertiary = LightAccentInk,
|
||||
tertiaryContainer = LightSurface3,
|
||||
onTertiaryContainer = LightInk,
|
||||
error = LightDanger,
|
||||
onError = LightAccentInk,
|
||||
errorContainer = LightSurface3,
|
||||
onErrorContainer = LightDanger,
|
||||
)
|
||||
|
||||
/** Dark is the product default (docs/DESIGN.md); light is opt-in, and dynamic
|
||||
* color (Material You, API 31+) is opt-in on top of that. Edge-to-edge itself
|
||||
* is enabled once in MainActivity#onCreate — [enableEdgeToEdge] draws content
|
||||
* behind transparent system bars for whichever scheme resolves here, and the
|
||||
* default `SystemBarStyle.auto` picks light/dark bar icons from this theme's
|
||||
* [darkTheme] flag, not the system setting. */
|
||||
@Composable
|
||||
fun MaestroTheme(
|
||||
darkTheme: Boolean = true,
|
||||
|
||||
@@ -1,17 +1,99 @@
|
||||
package pt.naps.maestro.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.sp
|
||||
import pt.naps.maestro.R
|
||||
|
||||
/** Bundled (SIL OFL, android/licenses/JetBrainsMono-OFL.txt) rather than the
|
||||
* system mono fallback, so the terminal pane and code blocks render the same
|
||||
* face as the web SPA (--font-mono) on every device. */
|
||||
val JetBrainsMono = FontFamily(
|
||||
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
|
||||
Font(R.font.jetbrains_mono_medium, FontWeight.Medium),
|
||||
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
|
||||
)
|
||||
|
||||
/** UI chrome uses the platform sans (--font-sans is a system-ui stack on
|
||||
* web); only terminal/code content switches to [JetBrainsMono]. */
|
||||
private val Sans = FontFamily.Default
|
||||
|
||||
/** Scale roughly mirrors the web token steps (web/src/styles.css --fs-11
|
||||
* through --fs-20); role names are Material3's, not the CSS ones. */
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = Sans,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = Sans,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 17.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = Sans,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = Sans,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = Sans,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = Sans,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = Sans,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = Sans,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.2.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = Sans,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp,
|
||||
letterSpacing = 0.2.sp,
|
||||
),
|
||||
)
|
||||
|
||||
/** For terminal output and code blocks — callers set this explicitly instead
|
||||
* of a [Typography] role, since mono content isn't part of the M3 type scale. */
|
||||
val MonoTextStyle = TextStyle(
|
||||
fontFamily = JetBrainsMono,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Matches web/public/icon.svg's background so the adaptive icon reads as
|
||||
the same mark as the PWA icon (issue #272). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#1B1B1B"
|
||||
android:fillColor="#1A1D24"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Foreground layer of the adaptive icon: the same concentric-circle mark
|
||||
as web/public/icon.svg, scaled from its 512 viewBox into this 108 one
|
||||
(issue #272). Stays inside the adaptive-icon safe zone (r=35.86 of 54
|
||||
half-width, ~66%). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#D0BCFF"
|
||||
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
|
||||
android:fillColor="#5FE3A1"
|
||||
android:pathData="M39.23,54 A14.77,14.77 0 1,0 68.77,54 A14.77,14.77 0 1,0 39.23,54 Z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#5FE3A1"
|
||||
android:strokeAlpha="0.5"
|
||||
android:strokeWidth="2.53"
|
||||
android:pathData="M28.69,54 A25.31,25.31 0 1,0 79.31,54 A25.31,25.31 0 1,0 28.69,54 Z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#5FE3A1"
|
||||
android:strokeAlpha="0.25"
|
||||
android:strokeWidth="2.11"
|
||||
android:pathData="M18.14,54 A35.86,35.86 0 1,0 89.86,54 A35.86,35.86 0 1,0 18.14,54 Z" />
|
||||
</vector>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<resources>
|
||||
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<!-- No statusBarColor override: MainActivity#onCreate calls
|
||||
enableEdgeToEdge(), which makes both system bars transparent at
|
||||
runtime (issue #271). Setting one here would only show briefly
|
||||
before that call lands. -->
|
||||
<item name="android:windowBackground">#FF0E1012</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package pt.naps.maestro.ui.commandbar
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.data.SessionStatus
|
||||
|
||||
private fun session(
|
||||
id: String,
|
||||
name: String = id,
|
||||
command: String = "bash",
|
||||
cwd: String = "/home/user/project",
|
||||
status: SessionStatus = SessionStatus.Running,
|
||||
createdAt: String = "2026-01-01T00:00:00Z",
|
||||
lastFocusedAt: String? = null,
|
||||
) = Session(
|
||||
id = id,
|
||||
cwd = cwd,
|
||||
command = command,
|
||||
metadata = mapOf("name" to JsonPrimitive(name)),
|
||||
status = status,
|
||||
created_at = createdAt,
|
||||
last_focused_at = lastFocusedAt,
|
||||
)
|
||||
|
||||
class CommandBarModelTest {
|
||||
@Test
|
||||
fun `a sigil is only read in first position`() {
|
||||
assertEquals(BarSigil.Commands, parseBarQuery(">quit").sigil)
|
||||
assertEquals("quit", parseBarQuery(">quit").query)
|
||||
assertEquals(BarSigil.Directories, parseBarQuery("~/dev").sigil)
|
||||
assertEquals("/dev", parseBarQuery("~/dev").query)
|
||||
assertNull(parseBarQuery("foo>bar").sigil)
|
||||
assertEquals("foo>bar", parseBarQuery("foo>bar").query)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backspacing past a sigil returns the mixed list`() {
|
||||
assertNull(parseBarQuery("").sigil)
|
||||
assertEquals("", parseBarQuery("").query)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `most recently focused session sorts first`() {
|
||||
val a = session("a", lastFocusedAt = "2026-01-01T00:00:00Z")
|
||||
val b = session("b", lastFocusedAt = "2026-01-02T00:00:00Z")
|
||||
assertEquals(listOf(b, a), orderSessionsForBar(listOf(a, b)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a never-focused session sorts after every focused one`() {
|
||||
val focused = session("a", lastFocusedAt = "2026-01-01T00:00:00Z")
|
||||
val neverFocused = session("b", createdAt = "2026-01-05T00:00:00Z")
|
||||
assertEquals(listOf(focused, neverFocused), orderSessionsForBar(listOf(neverFocused, focused)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `among never-focused sessions the most recently created sorts first`() {
|
||||
val older = session("a", createdAt = "2026-01-01T00:00:00Z")
|
||||
val newer = session("b", createdAt = "2026-01-02T00:00:00Z")
|
||||
assertEquals(listOf(newer, older), orderSessionsForBar(listOf(older, newer)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dead sessions sort last regardless of recency`() {
|
||||
val dead = session("a", status = SessionStatus.Dead, lastFocusedAt = "2026-01-09T00:00:00Z")
|
||||
val alive = session("b", lastFocusedAt = "2026-01-01T00:00:00Z")
|
||||
assertEquals(listOf(alive, dead), orderSessionsForBar(listOf(dead, alive)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `abbreviateCwd replaces the home prefix`() {
|
||||
assertEquals("~/dev/api", abbreviateCwd("/home/user/dev/api", "/home/user"))
|
||||
assertEquals("~", abbreviateCwd("/home/user", "/home/user"))
|
||||
assertEquals("/var/lib", abbreviateCwd("/var/lib", "/home/user"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expandHome inverts abbreviateCwd`() {
|
||||
assertEquals("/home/user/dev/api", expandHome("~/dev/api", "/home/user"))
|
||||
assertEquals("/home/user", expandHome("~", "/home/user"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty query matches every candidate at score 0 in input order`() {
|
||||
val a = BarCandidate.SessionItem(session("a"))
|
||||
val b = BarCandidate.SessionItem(session("b"))
|
||||
val results = rankCandidates("", listOf(a, b), homeDir = "")
|
||||
assertEquals(listOf(a, b), results.map { it.candidate })
|
||||
assertTrue(results.all { it.score == 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a query matching nothing returns an empty list`() {
|
||||
val candidates = listOf(BarCandidate.SessionItem(session("a", command = "bash")))
|
||||
assertTrue(rankCandidates("zzz", candidates, homeDir = "").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exact name match outranks a session found through its directory`() {
|
||||
val api = BarCandidate.SessionItem(session("api", cwd = "/home/user/web"))
|
||||
val worker = BarCandidate.SessionItem(session("worker", cwd = "/home/user/api"))
|
||||
val results = rankCandidates("api", listOf(api, worker), homeDir = "")
|
||||
assertEquals(listOf(api, worker), results.map { it.candidate })
|
||||
assertEquals("name", results[0].matchedField)
|
||||
assertEquals("directory", results[1].matchedField)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session beats a command of equal score, kind order tie-break`() {
|
||||
val sessionItem = BarCandidate.SessionItem(session("kill"))
|
||||
val command = BarCandidate.CommandItem(BarCommand(id = "kill", label = "kill"))
|
||||
val results = rankCandidates("kill", listOf(command, sessionItem), homeDir = "")
|
||||
assertEquals(listOf(sessionItem, command), results.map { it.candidate })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `groups are ordered by their best-scoring member and drop empty kinds`() {
|
||||
val sessionItem = BarCandidate.SessionItem(session("web-search"))
|
||||
val command = BarCandidate.CommandItem(BarCommand(id = "spawn", label = "Spawn session"))
|
||||
val ranked = rankCandidates("web", listOf(command, sessionItem), homeDir = "")
|
||||
val groups = groupResults(ranked)
|
||||
assertEquals(listOf("Sessions"), groups.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fuzzy subsequence still matches out of order characters`() {
|
||||
val candidate = BarCandidate.SessionItem(session("maestro-tui"))
|
||||
assertTrue(rankCandidates("mtu", listOf(candidate), homeDir = "").isNotEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Reference in New Issue
Block a user