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:
2026-10-01 09:54:02 +00:00
parent 66ef62913a
commit 662e031b6b
10 changed files with 501 additions and 48 deletions

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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 }

View 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 }
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -0,0 +1,22 @@
package com.ts3client.net;
import com.ts3client.text.TextQuery;
/** What finding in the server tree matches a client or a channel on. */
public final class TreeMatch {
private TreeMatch() {
}
/** A client by their nickname, or by the name the contacts give them ({@code null} for none). */
public static boolean client(TextQuery query, String nickname, String contactName) {
return query.matches(nickname) || (contactName != null && query.matches(contactName));
}
/** A channel by its name; a spacer by its caption, unless that only repeats to fill the row. */
public static boolean channel(TextQuery query, String name) {
Spacers.Spacer spacer = Spacers.parse(name);
if (spacer == null) return query.matches(name);
return spacer.align() != '*' && query.matches(spacer.caption());
}
}

View File

@@ -3,8 +3,8 @@ package com.ts3client.ui;
import com.ts3client.contacts.Contact;
import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.Spacers;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.TreeMatch;
import com.ts3client.text.TextQuery;
import javax.swing.Icon;
@@ -102,14 +102,9 @@ final class ServerTreeSearch implements FindBar.Listener {
if (node instanceof ClientEntry c) {
if (searchChannels) return false;
Contact contact = contacts.find(c.uniqueId);
return query.matches(c.nickname) || (contact != null && query.matches(contact.displayName(c.nickname)));
}
if (node instanceof ChannelNode ch) {
if (!searchChannels) return false;
Spacers.Spacer spacer = Spacers.parse(ch.name);
if (spacer == null) return query.matches(ch.name);
return spacer.align() != '*' && query.matches(spacer.caption());
return TreeMatch.client(query, c.nickname, contact != null ? contact.displayName(c.nickname) : null);
}
if (node instanceof ChannelNode ch) return searchChannels && TreeMatch.channel(query, ch.name);
return false;
}