From 662e031b6b39862372b5e0809ec98696e3a00aee Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 1 Oct 2026 09:54:02 +0000 Subject: [PATCH] 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 --- .../main/java/com/ts3client/android/Chats.kt | 24 ++- .../java/com/ts3client/android/ServerTree.kt | 46 +++++ .../com/ts3client/android/ui/ChannelTree.kt | 57 ++++-- .../java/com/ts3client/android/ui/ChatPane.kt | 39 +++- .../java/com/ts3client/android/ui/ChatText.kt | 34 +++- .../java/com/ts3client/android/ui/FindBar.kt | 190 ++++++++++++++++++ .../com/ts3client/android/ui/ServerScreen.kt | 118 +++++++++-- .../java/com/ts3client/android/ui/Theme.kt | 8 + .../java/com/ts3client/net/TreeMatch.java | 22 ++ .../com/ts3client/ui/ServerTreeSearch.java | 11 +- 10 files changed, 501 insertions(+), 48 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/ui/FindBar.kt create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/TreeMatch.java diff --git a/android/app/src/main/java/com/ts3client/android/Chats.kt b/android/app/src/main/java/com/ts3client/android/Chats.kt index 182d810..9857005 100644 --- a/android/app/src/main/java/com/ts3client/android/Chats.kt +++ b/android/app/src/main/java/com/ts3client/android/Chats.kt @@ -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, query: TextQuery, limit: Int): List { + if (query.isEmpty) return emptyList() + val out = ArrayList() + 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 +} diff --git a/android/app/src/main/java/com/ts3client/android/ServerTree.kt b/android/app/src/main/java/com/ts3client/android/ServerTree.kt index 17a9c50..51b82b1 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerTree.kt @@ -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? { + fun walk(items: List, above: List): List? { + 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 { + val out = ArrayList() + fun walk(items: List) { + 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, 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, diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt index a0c3bc4..02937e9 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt @@ -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 = emptySet(), + foundCurrent: TreeRef? = null, ) { val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) } val listState = rememberLazyListState() - var flashing by remember { mutableStateOf(null) } + var flashing by remember { mutableStateOf(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) diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt b/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt index 2cd7b04..a8cb753 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChatPane.kt @@ -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 = 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() } 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, onLink: (String) -> Unit, modifier: Modifier) { +private fun Lines( + lines: List, + 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, 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) } } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt b/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt index 0763b4b..dacda10 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt @@ -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, find: TextQuery, current: Int, match: Color, currentMatch: Color): List { + 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 { null } diff --git a/android/app/src/main/java/com/ts3client/android/ui/FindBar.kt b/android/app/src/main/java/com/ts3client/android/ui/FindBar.kt new file mode 100644 index 0000000..a645159 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/FindBar.kt @@ -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( + 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 } + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt index a664cd2..cccc3b6 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt @@ -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(null) } var clientSheetFromTree by remember { mutableStateOf(false) } - /** A client to show in the tree. */ - var reveal by remember { mutableStateOf(null) } + /** A row to show in the tree. */ + var reveal by remember { mutableStateOf(null) } var channelSheet by remember { mutableStateOf(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()) } + /** The match the tree is on. */ + var treeAt by remember { mutableStateOf(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(null) } + var chatReveal by remember { mutableStateOf(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) { diff --git a/android/app/src/main/java/com/ts3client/android/ui/Theme.kt b/android/app/src/main/java/com/ts3client/android/ui/Theme.kt index c7895d2..b70e551 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/Theme.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/Theme.kt @@ -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) { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TreeMatch.java b/ts3-client/core/src/main/java/com/ts3client/net/TreeMatch.java new file mode 100644 index 0000000..71a61f7 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/TreeMatch.java @@ -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()); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeSearch.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeSearch.java index dc3ee9a..9ff0032 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeSearch.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreeSearch.java @@ -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; }