Find in the channel tree and the chat on Android
A find button in the top bar opens a find bar over the tree (clients or channels) or the conversation on screen, with match stepping, highlights, only-matches filtering and the desktop's matching options. The tree's match rule moves to core TreeMatch, which the desktop now uses too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package com.ts3client.android
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.ts3client.text.HtmlText
|
||||
import com.ts3client.text.TextQuery
|
||||
|
||||
/** Which conversation a chat line belongs to. */
|
||||
sealed interface ChatKey {
|
||||
@@ -11,7 +13,10 @@ sealed interface ChatKey {
|
||||
|
||||
/** One entry of a chat, as HTML in the form [com.ts3client.text.ChatHtml] writes. */
|
||||
@Immutable
|
||||
data class ChatLine(val id: Long, val html: String)
|
||||
data class ChatLine(val id: Long, val html: String) {
|
||||
/** The text to find in. */
|
||||
val plain: String by lazy { HtmlText.plain(html) }
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class Conversation(
|
||||
@@ -64,3 +69,20 @@ data class Chats(
|
||||
|
||||
fun Conversation.append(line: ChatLine, read: Boolean): Conversation =
|
||||
copy(lines = (lines + line).takeLast(Chats.MAX_LINES), unread = unread || !read)
|
||||
|
||||
/** The [nth] match of the find bar's query in the line [line]. */
|
||||
data class ChatMatch(val line: Long, val nth: Int)
|
||||
|
||||
/** Every match of [query] in [lines], oldest first; past [limit], only the newest. */
|
||||
fun findIn(lines: List<ChatLine>, query: TextQuery, limit: Int): List<ChatMatch> {
|
||||
if (query.isEmpty) return emptyList()
|
||||
val out = ArrayList<ChatMatch>()
|
||||
for (line in lines) {
|
||||
val m = query.matcher(line.plain) ?: return emptyList()
|
||||
var n = 0
|
||||
while (m.find()) {
|
||||
if (m.end() > m.start()) out += ChatMatch(line.id, n++)
|
||||
}
|
||||
}
|
||||
return if (out.size > limit) out.subList(out.size - limit, out.size) else out
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.ts3client.net.ClientEntry
|
||||
import com.ts3client.net.ClientStatus
|
||||
import com.ts3client.net.ServerModel
|
||||
import com.ts3client.net.Spacers
|
||||
import com.ts3client.net.TreeMatch
|
||||
import com.ts3client.text.TextQuery
|
||||
|
||||
/**
|
||||
* A copy of the channel tree taken at one moment. The model's own nodes change in place
|
||||
@@ -42,6 +44,43 @@ data class ServerTree(
|
||||
return walk(channels)
|
||||
}
|
||||
|
||||
/** The channels from the root down to [channelId]'s parent; null when it is not in the tree. */
|
||||
fun parentsOf(channelId: Int): List<Int>? {
|
||||
fun walk(items: List<ChannelItem>, above: List<Int>): List<Int>? {
|
||||
for (c in items) {
|
||||
if (c.id == channelId) return above
|
||||
walk(c.children, above + c.id)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(channels, emptyList())
|
||||
}
|
||||
|
||||
/** The ids of what [find] matches, in reading order. */
|
||||
fun hits(find: TreeFind): List<Int> {
|
||||
val out = ArrayList<Int>()
|
||||
fun walk(items: List<ChannelItem>) {
|
||||
for (c in items) {
|
||||
if (find.matches(c)) out += c.id
|
||||
c.clients.forEach { if (find.matches(it)) out += it.id }
|
||||
walk(c.children)
|
||||
}
|
||||
}
|
||||
if (!find.query.isEmpty) walk(channels)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Only what [find] matches: a matching channel with its clients, a matching client, and the channels leading to either. */
|
||||
fun only(find: TreeFind): ServerTree {
|
||||
fun keep(c: ChannelItem): ChannelItem? {
|
||||
val matches = find.matches(c)
|
||||
val clients = if (matches) c.clients else c.clients.filter(find::matches)
|
||||
val children = c.children.mapNotNull(::keep)
|
||||
return if (matches || clients.isNotEmpty() || children.isNotEmpty()) c.copy(clients = clients, children = children) else null
|
||||
}
|
||||
return copy(channels = channels.mapNotNull(::keep))
|
||||
}
|
||||
|
||||
private fun find(items: List<ChannelItem>, test: (ChannelItem) -> Boolean): ChannelItem? {
|
||||
for (c in items) {
|
||||
if (test(c)) return c
|
||||
@@ -97,6 +136,13 @@ data class ServerTree(
|
||||
}
|
||||
}
|
||||
|
||||
/** What the find bar looks for in the tree: clients, or with [channels] channels. */
|
||||
@Immutable
|
||||
data class TreeFind(val query: TextQuery, val channels: Boolean) {
|
||||
fun matches(c: ChannelItem) = channels && TreeMatch.channel(query, c.name)
|
||||
fun matches(c: ClientItem) = !channels && TreeMatch.client(query, c.nickname, c.name)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ChannelItem(
|
||||
val id: Int,
|
||||
|
||||
@@ -51,17 +51,21 @@ import com.ts3client.net.ClientStatus
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/** A channel's or a client's row. */
|
||||
data class TreeRef(val channel: Boolean, val id: Int)
|
||||
|
||||
/** One line of the tree as the list shows it. */
|
||||
private sealed interface TreeRow {
|
||||
val key: String
|
||||
val ref: TreeRef
|
||||
val key: String get() = (if (ref.channel) "c" else "u") + ref.id
|
||||
|
||||
data class Channel(val channel: ChannelItem, val depth: Int, val expanded: Boolean, val current: Boolean) : TreeRow {
|
||||
override val key get() = "c${channel.id}"
|
||||
override val ref get() = TreeRef(true, channel.id)
|
||||
val expandable get() = channel.clients.isNotEmpty() || channel.children.isNotEmpty()
|
||||
}
|
||||
|
||||
data class Client(val client: ClientItem, val depth: Int) : TreeRow {
|
||||
override val key get() = "u${client.id}"
|
||||
override val ref get() = TreeRef(false, client.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,21 +94,24 @@ fun ChannelTree(
|
||||
onClient: (ClientItem) -> Unit,
|
||||
onServer: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/** A client to scroll to and flash, once their channels are expanded. */
|
||||
reveal: Int? = null,
|
||||
/** A row to scroll to and flash, once the channels above it are expanded. */
|
||||
reveal: TreeRef? = null,
|
||||
onRevealed: () -> Unit = {},
|
||||
/** The rows the find bar matched, and the one it is on, which a reveal does not flash. */
|
||||
found: Set<TreeRef> = emptySet(),
|
||||
foundCurrent: TreeRef? = null,
|
||||
) {
|
||||
val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) }
|
||||
val listState = rememberLazyListState()
|
||||
var flashing by remember { mutableStateOf<Int?>(null) }
|
||||
var flashing by remember { mutableStateOf<TreeRef?>(null) }
|
||||
LaunchedEffect(reveal, rows) {
|
||||
val id = reveal ?: return@LaunchedEffect
|
||||
val index = rows.indexOfFirst { it is TreeRow.Client && it.client.id == id }
|
||||
val ref = reveal ?: return@LaunchedEffect
|
||||
val index = rows.indexOfFirst { it.ref == ref }
|
||||
if (index < 0) return@LaunchedEffect
|
||||
// Index 0 is the server's row. The client ends up in the middle of the list.
|
||||
// Index 0 is the server's row. The row ends up in the middle of the list.
|
||||
val middle = snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 } / 2
|
||||
listState.animateScrollToItem(index + 1, -middle)
|
||||
flashing = id
|
||||
if (ref != foundCurrent) flashing = ref
|
||||
onRevealed()
|
||||
}
|
||||
LaunchedEffect(flashing) {
|
||||
@@ -123,9 +130,17 @@ fun ChannelTree(
|
||||
}
|
||||
}
|
||||
items(rows, key = { it.key }) { row ->
|
||||
val background = rowBackground(
|
||||
when (row.ref) {
|
||||
flashing -> FLASH
|
||||
foundCurrent -> findCurrentColor()
|
||||
in found -> findMatchColor()
|
||||
else -> Color.Transparent
|
||||
},
|
||||
)
|
||||
when (row) {
|
||||
is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel)
|
||||
is TreeRow.Client -> ClientRow(row, tree.countryFlags, row.client.id == flashing, onClient)
|
||||
is TreeRow.Channel -> ChannelRow(row, background, onToggle, onChannel)
|
||||
is TreeRow.Client -> ClientRow(row, tree.countryFlags, background, onClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +152,13 @@ private val ROW = 26.dp
|
||||
/** Amber, see-through so the row's text keeps its own colour and contrast. */
|
||||
private val FLASH = Color(0x80FFB300)
|
||||
|
||||
/** Fades in fast and out slowly, so a flash is seen. */
|
||||
@Composable
|
||||
private fun rowBackground(target: Color): Color {
|
||||
val color by animateColorAsState(target, tween(if (target == FLASH) 150 else 600), label = "row")
|
||||
return color
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowLayout(
|
||||
depth: Int,
|
||||
@@ -159,10 +181,10 @@ private fun RowLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(row: TreeRow.Channel, onToggle: (ChannelItem) -> Unit, onChannel: (ChannelItem) -> Unit) {
|
||||
private fun ChannelRow(row: TreeRow.Channel, background: Color, onToggle: (ChannelItem) -> Unit, onChannel: (ChannelItem) -> Unit) {
|
||||
val channel = row.channel
|
||||
val spacer = channel.spacer
|
||||
RowLayout(row.depth, onClick = { onChannel(channel) }) {
|
||||
RowLayout(row.depth, onClick = { onChannel(channel) }, background = background) {
|
||||
Box(
|
||||
Modifier.fillMaxHeight().width(ICON + 4.dp)
|
||||
.then(if (row.expandable) Modifier.clickable { onToggle(channel) } else Modifier),
|
||||
@@ -210,13 +232,8 @@ private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, flashing: Boolean, onClient: (ClientItem) -> Unit) {
|
||||
private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, background: Color, onClient: (ClientItem) -> Unit) {
|
||||
val client = row.client
|
||||
val background by animateColorAsState(
|
||||
if (flashing) FLASH else FLASH.copy(alpha = 0f),
|
||||
tween(if (flashing) 150 else 600),
|
||||
label = "flash",
|
||||
)
|
||||
RowLayout(row.depth, onClick = { onClient(client) }, background = background) {
|
||||
Spacer(Modifier.width(ICON + 4.dp))
|
||||
StatusIcon(client.status)
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
@@ -47,8 +48,11 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ts3client.android.ChatKey
|
||||
import com.ts3client.android.ChatLine
|
||||
import com.ts3client.android.ChatMatch
|
||||
import com.ts3client.android.Chats
|
||||
import com.ts3client.android.Conversation
|
||||
import com.ts3client.text.TextQuery
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
@Composable
|
||||
fun ChatPane(
|
||||
@@ -60,6 +64,14 @@ fun ChatPane(
|
||||
onSend: (String) -> Unit,
|
||||
onLink: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/** The lines to show, when not all of [selected]'s. */
|
||||
lines: List<ChatLine> = selected.lines,
|
||||
find: TextQuery = TextQuery.EMPTY,
|
||||
/** The match the find bar is on. */
|
||||
current: ChatMatch? = null,
|
||||
/** A line to scroll to. */
|
||||
reveal: Long? = null,
|
||||
onRevealed: () -> Unit = {},
|
||||
) {
|
||||
Column(modifier) {
|
||||
LazyRow(
|
||||
@@ -71,7 +83,7 @@ fun ChatPane(
|
||||
}
|
||||
}
|
||||
// Each conversation opens at its newest line.
|
||||
key(selected.key) { Lines(selected.lines, onLink, Modifier.weight(1f)) }
|
||||
key(selected.key) { Lines(lines, onLink, Modifier.weight(1f), find, current, reveal, onRevealed) }
|
||||
// One unsent message per conversation, kept while another one is on screen.
|
||||
val drafts = rememberSaveable(saver = DraftsSaver) { mutableStateMapOf<String, String>() }
|
||||
val draftKey = selected.key.toString()
|
||||
@@ -130,7 +142,15 @@ private fun ConversationChip(
|
||||
|
||||
/** The conversation's lines, following new ones while the newest is in view. */
|
||||
@Composable
|
||||
private fun Lines(lines: List<ChatLine>, onLink: (String) -> Unit, modifier: Modifier) {
|
||||
private fun Lines(
|
||||
lines: List<ChatLine>,
|
||||
onLink: (String) -> Unit,
|
||||
modifier: Modifier,
|
||||
find: TextQuery,
|
||||
current: ChatMatch?,
|
||||
reveal: Long?,
|
||||
onRevealed: () -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = (lines.size - 1).coerceAtLeast(0))
|
||||
val link by rememberUpdatedState(onLink)
|
||||
LaunchedEffect(lines.lastOrNull()?.id) {
|
||||
@@ -138,9 +158,22 @@ private fun Lines(lines: List<ChatLine>, onLink: (String) -> Unit, modifier: Mod
|
||||
val atEnd = info.visibleItemsInfo.lastOrNull()?.index?.let { it >= info.totalItemsCount - 2 } ?: true
|
||||
if (atEnd && lines.isNotEmpty()) listState.scrollToItem(lines.size - 1)
|
||||
}
|
||||
LaunchedEffect(reveal, lines) {
|
||||
val id = reveal ?: return@LaunchedEffect
|
||||
val index = lines.indexOfFirst { it.id == id }
|
||||
if (index < 0) return@LaunchedEffect
|
||||
val info = listState.layoutInfo
|
||||
if (info.visibleItemsInfo.none { it.index == index && it.offset >= 0 && it.offset + it.size <= info.viewportEndOffset }) {
|
||||
// The line's top ends up in the middle of the list.
|
||||
val middle = snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 } / 2
|
||||
listState.animateScrollToItem(index, -middle)
|
||||
}
|
||||
onRevealed()
|
||||
}
|
||||
LazyColumn(modifier.fillMaxWidth(), state = listState, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
items(lines, key = { it.id }) { line ->
|
||||
RichText(line.html, { link(it) }, Modifier.padding(vertical = 2.dp), MaterialTheme.typography.bodyMedium)
|
||||
RichText(line.html, { link(it) }, Modifier.padding(vertical = 2.dp), MaterialTheme.typography.bodyMedium,
|
||||
find, if (current?.line == line.id) current.nth else -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ts3client.android.WebPictures
|
||||
import com.ts3client.text.BBCode
|
||||
import com.ts3client.text.ChatHtml
|
||||
import com.ts3client.text.TextQuery
|
||||
|
||||
/**
|
||||
* HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers
|
||||
@@ -40,10 +41,23 @@ import com.ts3client.text.ChatHtml
|
||||
* split at them.
|
||||
*/
|
||||
@Composable
|
||||
fun RichText(html: String, onLink: (String) -> Unit, modifier: Modifier = Modifier, style: TextStyle = LocalTextStyle.current) {
|
||||
fun RichText(
|
||||
html: String,
|
||||
onLink: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
/** Matches to highlight: [current] is the index of the one the find bar is on, if it is here. */
|
||||
find: TextQuery = TextQuery.EMPTY,
|
||||
current: Int = -1,
|
||||
) {
|
||||
val link by rememberUpdatedState(onLink)
|
||||
val color = linkColor()
|
||||
val pieces = remember(html, color) { split(html) { chatText(it, color) { href -> link(href) } } }
|
||||
val parsed = remember(html, color) { split(html) { chatText(it, color) { href -> link(href) } } }
|
||||
val match = findMatchColor()
|
||||
val currentMatch = findCurrentColor()
|
||||
val pieces = remember(parsed, find, current, match, currentMatch) {
|
||||
if (find.isEmpty) parsed else highlight(parsed, find, current, match, currentMatch)
|
||||
}
|
||||
Column(modifier) {
|
||||
for (piece in pieces) {
|
||||
when (piece) {
|
||||
@@ -56,6 +70,22 @@ fun RichText(html: String, onLink: (String) -> Unit, modifier: Modifier = Modifi
|
||||
}
|
||||
}
|
||||
|
||||
/** [pieces] with [find]'s matches behind [match], and the [current]th of them, counting across pieces, behind [currentMatch]. */
|
||||
private fun highlight(pieces: List<Piece>, find: TextQuery, current: Int, match: Color, currentMatch: Color): List<Piece> {
|
||||
var n = 0
|
||||
return pieces.map { piece ->
|
||||
if (piece !is Piece.Text) return@map piece
|
||||
val m = find.matcher(piece.text.text) ?: return@map piece
|
||||
val text = AnnotatedString.Builder(piece.text)
|
||||
while (m.find()) {
|
||||
if (m.end() == m.start()) continue
|
||||
text.addStyle(SpanStyle(background = if (n == current) currentMatch else match), m.start(), m.end())
|
||||
n++
|
||||
}
|
||||
Piece.Text(text.toAnnotatedString())
|
||||
}
|
||||
}
|
||||
|
||||
/** The web pictures descriptions show. */
|
||||
val LocalWebPictures = staticCompositionLocalOf<WebPictures?> { null }
|
||||
|
||||
|
||||
190
android/app/src/main/java/com/ts3client/android/ui/FindBar.kt
Normal file
190
android/app/src/main/java/com/ts3client/android/ui/FindBar.kt
Normal file
@@ -0,0 +1,190 @@
|
||||
package com.ts3client.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isShiftPressed
|
||||
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.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ts3client.gfx.IconKeys
|
||||
import com.ts3client.net.ClientStatus
|
||||
import com.ts3client.text.TextQuery
|
||||
|
||||
/** What the find bar holds: the text, how to match it, and whether it is [open]. */
|
||||
@Stable
|
||||
class FindState(text: String = "", flags: Int = 0) {
|
||||
var open by mutableStateOf(false)
|
||||
var input by mutableStateOf(TextFieldValue(text))
|
||||
var matchCase by mutableStateOf(flags and 1 != 0)
|
||||
var wholeWord by mutableStateOf(flags and 2 != 0)
|
||||
var regex by mutableStateOf(flags and 4 != 0)
|
||||
/** Counts the times it was asked to show, for the field to take the focus each time. */
|
||||
var shown by mutableStateOf(0)
|
||||
private set
|
||||
/** Show only what matches. */
|
||||
var filter by mutableStateOf(flags and 8 != 0)
|
||||
/** In the tree, find channels rather than clients. */
|
||||
var channels by mutableStateOf(flags and 16 != 0)
|
||||
|
||||
/** What is typed, as it is to be matched; empty while the bar is closed. */
|
||||
val query: TextQuery
|
||||
get() = if (open) TextQuery(input.text, matchCase, wholeWord, regex) else TextQuery.EMPTY
|
||||
|
||||
/** [query], but nothing when it is not a valid regular expression. */
|
||||
val usable: TextQuery
|
||||
get() = query.let { if (it.isInvalid) TextQuery.EMPTY else it }
|
||||
|
||||
/** Opens the bar with what it holds selected, to be typed over. */
|
||||
fun show() {
|
||||
input = input.copy(selection = TextRange(0, input.text.length))
|
||||
open = true
|
||||
shown++
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Saver = listSaver<FindState, Any>(
|
||||
save = {
|
||||
val flags = (if (it.matchCase) 1 else 0) or (if (it.wholeWord) 2 else 0) or (if (it.regex) 4 else 0) or
|
||||
(if (it.filter) 8 else 0) or (if (it.channels) 16 else 0)
|
||||
listOf(it.input.text, flags, it.open)
|
||||
},
|
||||
restore = { FindState(it[0] as String, it[1] as Int).apply { open = it[2] as Boolean } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The find bar, in the place of the top bar: the field, where the view is among the matches,
|
||||
* previous/next, and the options. Enter goes to the next match, Shift+Enter to the previous.
|
||||
*
|
||||
* @param inTree whether it finds in the tree, which offers finding channels instead of clients
|
||||
* @param position the 1-based match the view is on, or 0 for none
|
||||
* @param more whether there are more matches than [total]
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FindBar(state: FindState, inTree: Boolean, position: Int, total: Int, more: Boolean, onStep: (Int) -> Unit) {
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(state.shown) { focus.requestFocus() }
|
||||
val query = state.query
|
||||
TopAppBar(
|
||||
expandedHeight = 48.dp,
|
||||
navigationIcon = { IconButton(onClick = { state.open = false }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Close") } },
|
||||
title = {
|
||||
TextField(
|
||||
state.input, { state.input = it },
|
||||
Modifier.focusRequester(focus).onPreviewKeyEvent { e ->
|
||||
when {
|
||||
e.key == Key.Enter -> {
|
||||
if (e.type == KeyEventType.KeyDown) onStep(if (e.isShiftPressed) -1 else 1)
|
||||
true
|
||||
}
|
||||
e.key == Key.Escape && e.type == KeyEventType.KeyDown -> {
|
||||
state.open = false
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(if (!inTree) "Find" else if (state.channels) "Find channels" else "Find clients", maxLines = 1)
|
||||
},
|
||||
leadingIcon = if (inTree) ({
|
||||
IconButton(onClick = { state.channels = !state.channels }) {
|
||||
if (state.channels) PackIcon(IconKeys.channel(false, false, true).asList(), "Finding channels", size = 18.dp)
|
||||
else PackIcon(IconKeys.client(ClientStatus.IDLE), "Finding clients", size = 18.dp)
|
||||
}
|
||||
}) else null,
|
||||
trailingIcon = if (state.input.text.isNotEmpty()) ({
|
||||
IconButton(onClick = { state.input = TextFieldValue() }) { Icon(Icons.Filled.Close, "Clear", Modifier.size(18.dp)) }
|
||||
}) else null,
|
||||
isError = query.isInvalid,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { onStep(1) }),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
errorContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!query.isEmpty) {
|
||||
Text(
|
||||
if (query.isInvalid) "!" else "${if (position > 0) "$position/" else ""}$total${if (more) "+" else ""}",
|
||||
Modifier.padding(horizontal = 4.dp),
|
||||
color = if (query.isInvalid || total == 0) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
val navigable = total > 1 || (total == 1 && position == 0)
|
||||
IconButton(onClick = { onStep(-1) }, enabled = navigable) { Icon(Icons.Filled.KeyboardArrowUp, "Previous") }
|
||||
IconButton(onClick = { onStep(1) }, enabled = navigable) { Icon(Icons.Filled.KeyboardArrowDown, "Next") }
|
||||
FindOptions(state)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FindOptions(state: FindState) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { open = true }) { Icon(Icons.Filled.MoreVert, "Options") }
|
||||
DropdownMenu(open, onDismissRequest = { open = false }) {
|
||||
@Composable
|
||||
fun option(label: String, checked: Boolean, set: (Boolean) -> Unit) = DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = { set(!checked) },
|
||||
leadingIcon = { Checkbox(checked, set) },
|
||||
)
|
||||
option("Only matches", state.filter) { state.filter = it }
|
||||
option("Match case", state.matchCase) { state.matchCase = it }
|
||||
option("Whole words", state.wholeWord) { state.wholeWord = it }
|
||||
option("Regular expression", state.regex) { state.regex = it }
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
@@ -65,10 +71,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ts3client.android.ChatKey
|
||||
import com.ts3client.android.ChatMatch
|
||||
import com.ts3client.android.ServerController
|
||||
import com.ts3client.android.ServerTree
|
||||
import com.ts3client.android.SessionController
|
||||
import com.ts3client.android.SessionState
|
||||
import com.ts3client.android.TreeFind
|
||||
import com.ts3client.android.findIn
|
||||
import com.ts3client.config.Settings
|
||||
import com.ts3client.net.ClientStatus
|
||||
import com.ts3client.text.TsLink
|
||||
@@ -114,8 +123,8 @@ fun ServerScreen(
|
||||
val conversation = chats[chatKey] ?: chats.channel
|
||||
var clientSheet by remember { mutableStateOf<Int?>(null) }
|
||||
var clientSheetFromTree by remember { mutableStateOf(false) }
|
||||
/** A client to show in the tree. */
|
||||
var reveal by remember { mutableStateOf<Int?>(null) }
|
||||
/** A row to show in the tree. */
|
||||
var reveal by remember { mutableStateOf<TreeRef?>(null) }
|
||||
var channelSheet by remember { mutableStateOf<Int?>(null) }
|
||||
var serverSheet by remember { mutableStateOf(false) }
|
||||
/** The client to move, while a channel for them is being picked. */
|
||||
@@ -146,19 +155,82 @@ fun ServerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Find: in the tree, clients or channels; in the chat, the conversation on screen.
|
||||
val find = rememberSaveable(saver = FindState.Saver) { FindState() }
|
||||
val treeFind = TreeFind(find.usable, find.channels)
|
||||
val treeHits = remember(tree, treeFind) { tree.hits(treeFind) }
|
||||
val treeFiltering = find.filter && !treeFind.query.isEmpty
|
||||
val shownTree = remember(tree, treeFind, treeFiltering) { if (treeFiltering) tree.only(treeFind) else tree }
|
||||
// While the tree is filtered every channel opens, so every match shows; folding one then lasts as long as the filter.
|
||||
var filterCollapsed by remember(treeFiltering, treeFind) { mutableStateOf(emptySet<Int>()) }
|
||||
/** The match the tree is on. */
|
||||
var treeAt by remember { mutableStateOf<Int?>(null) }
|
||||
// Lambdas, not local functions: Compose remembers a reference to a local function with what it first captured.
|
||||
val showTreeHit = { id: Int? ->
|
||||
if (id != null) {
|
||||
treeAt = id
|
||||
val path = (if (treeFind.channels) tree.parentsOf(id) else tree.pathTo(id)).orEmpty()
|
||||
if (treeFiltering) filterCollapsed = filterCollapsed - path.toSet() else server.expand(path)
|
||||
reveal = TreeRef(treeFind.channels, id)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(treeFind, showChat) {
|
||||
treeAt = null
|
||||
if (!showChat) showTreeHit(treeHits.firstOrNull())
|
||||
}
|
||||
|
||||
val chatQuery = find.usable
|
||||
val chatLines = remember(conversation.lines, chatQuery, find.filter) {
|
||||
if (find.filter && !chatQuery.isEmpty) conversation.lines.filter { chatQuery.matches(it.plain) } else conversation.lines
|
||||
}
|
||||
val chatHits = remember(chatLines, chatQuery) { findIn(chatLines, chatQuery, MAX_CHAT_MATCHES) }
|
||||
/** The match the chat is on; a new search starts from the newest. */
|
||||
var chatAt by remember { mutableStateOf<ChatMatch?>(null) }
|
||||
var chatReveal by remember { mutableStateOf<Long?>(null) }
|
||||
LaunchedEffect(chatQuery, conversation.key, find.filter, showChat) {
|
||||
chatAt = if (showChat) chatHits.lastOrNull() else null
|
||||
chatReveal = chatAt?.line
|
||||
}
|
||||
|
||||
val step = { delta: Int ->
|
||||
if (showChat && chatHits.isNotEmpty()) {
|
||||
chatAt = chatHits[next(chatHits.indexOf(chatAt), delta, chatHits.size)]
|
||||
chatReveal = chatAt?.line
|
||||
} else if (!showChat && treeHits.isNotEmpty()) {
|
||||
showTreeHit(treeHits[next(treeHits.indexOf(treeAt), delta, treeHits.size)])
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(showChat) { showChat = false }
|
||||
BackHandler(moving != null && !showChat) { moving = null }
|
||||
BackHandler(find.open) { find.open = false }
|
||||
|
||||
Scaffold(
|
||||
// A hardware keyboard's Ctrl+F, as on the desktop.
|
||||
modifier = Modifier.onPreviewKeyEvent { e ->
|
||||
val ctrlF = e.isCtrlPressed && e.key == Key.F
|
||||
if (ctrlF && e.type == KeyEventType.KeyDown) find.show()
|
||||
ctrlF
|
||||
},
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { ServerSwitcher(vm, server, state, onConnectOther) },
|
||||
expandedHeight = 48.dp,
|
||||
actions = {
|
||||
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
|
||||
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
|
||||
},
|
||||
)
|
||||
if (find.open) {
|
||||
if (showChat) {
|
||||
FindBar(find, inTree = false, chatHits.indexOf(chatAt) + 1, chatHits.size,
|
||||
chatHits.size == MAX_CHAT_MATCHES, step)
|
||||
} else {
|
||||
FindBar(find, inTree = true, treeHits.indexOf(treeAt) + 1, treeHits.size, false, step)
|
||||
}
|
||||
} else {
|
||||
TopAppBar(
|
||||
title = { ServerSwitcher(vm, server, state, onConnectOther) },
|
||||
expandedHeight = 48.dp,
|
||||
actions = {
|
||||
IconButton(onClick = find::show) { PackIcon("FIND", "Find") }
|
||||
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
|
||||
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
// Push-to-talk talks on whichever server holds the microphone, which need not be this one.
|
||||
@@ -187,10 +259,14 @@ fun ServerScreen(
|
||||
}
|
||||
ChannelTree(
|
||||
serverName = state.title,
|
||||
tree = tree,
|
||||
collapsed = collapsed,
|
||||
tree = shownTree,
|
||||
collapsed = if (treeFiltering) filterCollapsed else collapsed,
|
||||
currentChannel = state.channelId,
|
||||
onToggle = { server.toggleCollapsed(it.id) },
|
||||
onToggle = {
|
||||
if (!treeFiltering) server.toggleCollapsed(it.id)
|
||||
else if (it.id in filterCollapsed) filterCollapsed -= it.id
|
||||
else filterCollapsed += it.id
|
||||
},
|
||||
onChannel = { channel ->
|
||||
val client = moving
|
||||
when {
|
||||
@@ -204,6 +280,8 @@ fun ServerScreen(
|
||||
modifier = Modifier.weight(1f),
|
||||
reveal = reveal,
|
||||
onRevealed = { reveal = null },
|
||||
found = remember(treeHits) { treeHits.mapTo(HashSet()) { TreeRef(treeFind.channels, it) } },
|
||||
foundCurrent = treeAt?.takeIf { it in treeHits }?.let { TreeRef(treeFind.channels, it) },
|
||||
)
|
||||
} else {
|
||||
ChatPane(
|
||||
@@ -218,6 +296,11 @@ fun ServerScreen(
|
||||
onSend = { server.send(conversation.key, it) },
|
||||
onLink = onLink,
|
||||
modifier = Modifier.weight(1f),
|
||||
lines = chatLines,
|
||||
find = chatQuery,
|
||||
current = chatAt,
|
||||
reveal = chatReveal,
|
||||
onRevealed = { chatReveal = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -252,7 +335,7 @@ fun ServerScreen(
|
||||
clientSheet = null
|
||||
tree.pathTo(id)?.let { server.expand(it) }
|
||||
showChat = false
|
||||
reveal = id
|
||||
reveal = TreeRef(false, id)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -278,6 +361,13 @@ fun ServerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** More matches than this nobody steps through one by one. */
|
||||
private const val MAX_CHAT_MATCHES = 5000
|
||||
|
||||
/** The index [delta] on from [at] among [size], wrapping around; from none, the first or the last. */
|
||||
private fun next(at: Int, delta: Int, size: Int): Int =
|
||||
if (at < 0) (if (delta > 0) 0 else size - 1) else Math.floorMod(at + delta, size)
|
||||
|
||||
/** Says a channel is being picked for [name] to move to, until it is or [onCancel]. */
|
||||
@Composable
|
||||
private fun MovePrompt(name: String, onCancel: () -> Unit) {
|
||||
|
||||
@@ -21,6 +21,14 @@ object StatusColors {
|
||||
@Composable
|
||||
fun linkColor(): Color = if (isSystemInDarkTheme()) Color(0xFF7AB4FF) else Color(0xFF1A62D0)
|
||||
|
||||
/** Behind every match of the find bar's query, as on the desktop. */
|
||||
@Composable
|
||||
fun findMatchColor(): Color = if (isSystemInDarkTheme()) Color(0xFF5C4E1A) else Color(0xFFFFEBA3)
|
||||
|
||||
/** Behind the match the find bar is on. */
|
||||
@Composable
|
||||
fun findCurrentColor(): Color = if (isSystemInDarkTheme()) Color(0xFF9C6A12) else Color(0xFFFFB84D)
|
||||
|
||||
/** The system's own (wallpaper) colours, which every supported Android version offers. */
|
||||
@Composable
|
||||
fun Ts3Theme(content: @Composable () -> Unit) {
|
||||
|
||||
Reference in New Issue
Block a user