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
|
package com.ts3client.android
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.ts3client.text.HtmlText
|
||||||
|
import com.ts3client.text.TextQuery
|
||||||
|
|
||||||
/** Which conversation a chat line belongs to. */
|
/** Which conversation a chat line belongs to. */
|
||||||
sealed interface ChatKey {
|
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. */
|
/** One entry of a chat, as HTML in the form [com.ts3client.text.ChatHtml] writes. */
|
||||||
@Immutable
|
@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
|
@Immutable
|
||||||
data class Conversation(
|
data class Conversation(
|
||||||
@@ -64,3 +69,20 @@ data class Chats(
|
|||||||
|
|
||||||
fun Conversation.append(line: ChatLine, read: Boolean): Conversation =
|
fun Conversation.append(line: ChatLine, read: Boolean): Conversation =
|
||||||
copy(lines = (lines + line).takeLast(Chats.MAX_LINES), unread = unread || !read)
|
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.ClientStatus
|
||||||
import com.ts3client.net.ServerModel
|
import com.ts3client.net.ServerModel
|
||||||
import com.ts3client.net.Spacers
|
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
|
* 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)
|
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? {
|
private fun find(items: List<ChannelItem>, test: (ChannelItem) -> Boolean): ChannelItem? {
|
||||||
for (c in items) {
|
for (c in items) {
|
||||||
if (test(c)) return c
|
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
|
@Immutable
|
||||||
data class ChannelItem(
|
data class ChannelItem(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
|
|||||||
@@ -51,17 +51,21 @@ import com.ts3client.net.ClientStatus
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
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. */
|
/** One line of the tree as the list shows it. */
|
||||||
private sealed interface TreeRow {
|
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 {
|
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()
|
val expandable get() = channel.clients.isNotEmpty() || channel.children.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Client(val client: ClientItem, val depth: Int) : TreeRow {
|
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,
|
onClient: (ClientItem) -> Unit,
|
||||||
onServer: () -> Unit,
|
onServer: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
/** A client to scroll to and flash, once their channels are expanded. */
|
/** A row to scroll to and flash, once the channels above it are expanded. */
|
||||||
reveal: Int? = null,
|
reveal: TreeRef? = null,
|
||||||
onRevealed: () -> Unit = {},
|
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 rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
var flashing by remember { mutableStateOf<Int?>(null) }
|
var flashing by remember { mutableStateOf<TreeRef?>(null) }
|
||||||
LaunchedEffect(reveal, rows) {
|
LaunchedEffect(reveal, rows) {
|
||||||
val id = reveal ?: return@LaunchedEffect
|
val ref = reveal ?: return@LaunchedEffect
|
||||||
val index = rows.indexOfFirst { it is TreeRow.Client && it.client.id == id }
|
val index = rows.indexOfFirst { it.ref == ref }
|
||||||
if (index < 0) return@LaunchedEffect
|
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
|
val middle = snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 } / 2
|
||||||
listState.animateScrollToItem(index + 1, -middle)
|
listState.animateScrollToItem(index + 1, -middle)
|
||||||
flashing = id
|
if (ref != foundCurrent) flashing = ref
|
||||||
onRevealed()
|
onRevealed()
|
||||||
}
|
}
|
||||||
LaunchedEffect(flashing) {
|
LaunchedEffect(flashing) {
|
||||||
@@ -123,9 +130,17 @@ fun ChannelTree(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(rows, key = { it.key }) { row ->
|
items(rows, key = { it.key }) { row ->
|
||||||
|
val background = rowBackground(
|
||||||
|
when (row.ref) {
|
||||||
|
flashing -> FLASH
|
||||||
|
foundCurrent -> findCurrentColor()
|
||||||
|
in found -> findMatchColor()
|
||||||
|
else -> Color.Transparent
|
||||||
|
},
|
||||||
|
)
|
||||||
when (row) {
|
when (row) {
|
||||||
is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel)
|
is TreeRow.Channel -> ChannelRow(row, background, onToggle, onChannel)
|
||||||
is TreeRow.Client -> ClientRow(row, tree.countryFlags, row.client.id == flashing, onClient)
|
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. */
|
/** Amber, see-through so the row's text keeps its own colour and contrast. */
|
||||||
private val FLASH = Color(0x80FFB300)
|
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
|
@Composable
|
||||||
private fun RowLayout(
|
private fun RowLayout(
|
||||||
depth: Int,
|
depth: Int,
|
||||||
@@ -159,10 +181,10 @@ private fun RowLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 channel = row.channel
|
||||||
val spacer = channel.spacer
|
val spacer = channel.spacer
|
||||||
RowLayout(row.depth, onClick = { onChannel(channel) }) {
|
RowLayout(row.depth, onClick = { onChannel(channel) }, background = background) {
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxHeight().width(ICON + 4.dp)
|
Modifier.fillMaxHeight().width(ICON + 4.dp)
|
||||||
.then(if (row.expandable) Modifier.clickable { onToggle(channel) } else Modifier),
|
.then(if (row.expandable) Modifier.clickable { onToggle(channel) } else Modifier),
|
||||||
@@ -210,13 +232,8 @@ private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 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) {
|
RowLayout(row.depth, onClick = { onClient(client) }, background = background) {
|
||||||
Spacer(Modifier.width(ICON + 4.dp))
|
Spacer(Modifier.width(ICON + 4.dp))
|
||||||
StatusIcon(client.status)
|
StatusIcon(client.status)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.saveable.Saver
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -47,8 +48,11 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ts3client.android.ChatKey
|
import com.ts3client.android.ChatKey
|
||||||
import com.ts3client.android.ChatLine
|
import com.ts3client.android.ChatLine
|
||||||
|
import com.ts3client.android.ChatMatch
|
||||||
import com.ts3client.android.Chats
|
import com.ts3client.android.Chats
|
||||||
import com.ts3client.android.Conversation
|
import com.ts3client.android.Conversation
|
||||||
|
import com.ts3client.text.TextQuery
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatPane(
|
fun ChatPane(
|
||||||
@@ -60,6 +64,14 @@ fun ChatPane(
|
|||||||
onSend: (String) -> Unit,
|
onSend: (String) -> Unit,
|
||||||
onLink: (String) -> Unit,
|
onLink: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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) {
|
Column(modifier) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
@@ -71,7 +83,7 @@ fun ChatPane(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Each conversation opens at its newest line.
|
// 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.
|
// One unsent message per conversation, kept while another one is on screen.
|
||||||
val drafts = rememberSaveable(saver = DraftsSaver) { mutableStateMapOf<String, String>() }
|
val drafts = rememberSaveable(saver = DraftsSaver) { mutableStateMapOf<String, String>() }
|
||||||
val draftKey = selected.key.toString()
|
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. */
|
/** The conversation's lines, following new ones while the newest is in view. */
|
||||||
@Composable
|
@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 listState = rememberLazyListState(initialFirstVisibleItemIndex = (lines.size - 1).coerceAtLeast(0))
|
||||||
val link by rememberUpdatedState(onLink)
|
val link by rememberUpdatedState(onLink)
|
||||||
LaunchedEffect(lines.lastOrNull()?.id) {
|
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
|
val atEnd = info.visibleItemsInfo.lastOrNull()?.index?.let { it >= info.totalItemsCount - 2 } ?: true
|
||||||
if (atEnd && lines.isNotEmpty()) listState.scrollToItem(lines.size - 1)
|
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)) {
|
LazyColumn(modifier.fillMaxWidth(), state = listState, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
|
||||||
items(lines, key = { it.id }) { line ->
|
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.android.WebPictures
|
||||||
import com.ts3client.text.BBCode
|
import com.ts3client.text.BBCode
|
||||||
import com.ts3client.text.ChatHtml
|
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
|
* 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.
|
* split at them.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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 link by rememberUpdatedState(onLink)
|
||||||
val color = linkColor()
|
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) {
|
Column(modifier) {
|
||||||
for (piece in pieces) {
|
for (piece in pieces) {
|
||||||
when (piece) {
|
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. */
|
/** The web pictures descriptions show. */
|
||||||
val LocalWebPictures = staticCompositionLocalOf<WebPictures?> { null }
|
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.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
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.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.ts3client.android.ChatKey
|
import com.ts3client.android.ChatKey
|
||||||
|
import com.ts3client.android.ChatMatch
|
||||||
import com.ts3client.android.ServerController
|
import com.ts3client.android.ServerController
|
||||||
import com.ts3client.android.ServerTree
|
import com.ts3client.android.ServerTree
|
||||||
import com.ts3client.android.SessionController
|
import com.ts3client.android.SessionController
|
||||||
import com.ts3client.android.SessionState
|
import com.ts3client.android.SessionState
|
||||||
|
import com.ts3client.android.TreeFind
|
||||||
|
import com.ts3client.android.findIn
|
||||||
import com.ts3client.config.Settings
|
import com.ts3client.config.Settings
|
||||||
import com.ts3client.net.ClientStatus
|
import com.ts3client.net.ClientStatus
|
||||||
import com.ts3client.text.TsLink
|
import com.ts3client.text.TsLink
|
||||||
@@ -114,8 +123,8 @@ fun ServerScreen(
|
|||||||
val conversation = chats[chatKey] ?: chats.channel
|
val conversation = chats[chatKey] ?: chats.channel
|
||||||
var clientSheet by remember { mutableStateOf<Int?>(null) }
|
var clientSheet by remember { mutableStateOf<Int?>(null) }
|
||||||
var clientSheetFromTree by remember { mutableStateOf(false) }
|
var clientSheetFromTree by remember { mutableStateOf(false) }
|
||||||
/** A client to show in the tree. */
|
/** A row to show in the tree. */
|
||||||
var reveal by remember { mutableStateOf<Int?>(null) }
|
var reveal by remember { mutableStateOf<TreeRef?>(null) }
|
||||||
var channelSheet by remember { mutableStateOf<Int?>(null) }
|
var channelSheet by remember { mutableStateOf<Int?>(null) }
|
||||||
var serverSheet by remember { mutableStateOf(false) }
|
var serverSheet by remember { mutableStateOf(false) }
|
||||||
/** The client to move, while a channel for them is being picked. */
|
/** 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(showChat) { showChat = false }
|
||||||
BackHandler(moving != null && !showChat) { moving = null }
|
BackHandler(moving != null && !showChat) { moving = null }
|
||||||
|
BackHandler(find.open) { find.open = false }
|
||||||
|
|
||||||
Scaffold(
|
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 = {
|
topBar = {
|
||||||
|
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(
|
TopAppBar(
|
||||||
title = { ServerSwitcher(vm, server, state, onConnectOther) },
|
title = { ServerSwitcher(vm, server, state, onConnectOther) },
|
||||||
expandedHeight = 48.dp,
|
expandedHeight = 48.dp,
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = find::show) { PackIcon("FIND", "Find") }
|
||||||
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
|
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
|
||||||
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
|
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
// Push-to-talk talks on whichever server holds the microphone, which need not be this one.
|
// Push-to-talk talks on whichever server holds the microphone, which need not be this one.
|
||||||
@@ -187,10 +259,14 @@ fun ServerScreen(
|
|||||||
}
|
}
|
||||||
ChannelTree(
|
ChannelTree(
|
||||||
serverName = state.title,
|
serverName = state.title,
|
||||||
tree = tree,
|
tree = shownTree,
|
||||||
collapsed = collapsed,
|
collapsed = if (treeFiltering) filterCollapsed else collapsed,
|
||||||
currentChannel = state.channelId,
|
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 ->
|
onChannel = { channel ->
|
||||||
val client = moving
|
val client = moving
|
||||||
when {
|
when {
|
||||||
@@ -204,6 +280,8 @@ fun ServerScreen(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
reveal = reveal,
|
reveal = reveal,
|
||||||
onRevealed = { reveal = null },
|
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 {
|
} else {
|
||||||
ChatPane(
|
ChatPane(
|
||||||
@@ -218,6 +296,11 @@ fun ServerScreen(
|
|||||||
onSend = { server.send(conversation.key, it) },
|
onSend = { server.send(conversation.key, it) },
|
||||||
onLink = onLink,
|
onLink = onLink,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
lines = chatLines,
|
||||||
|
find = chatQuery,
|
||||||
|
current = chatAt,
|
||||||
|
reveal = chatReveal,
|
||||||
|
onRevealed = { chatReveal = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,7 +335,7 @@ fun ServerScreen(
|
|||||||
clientSheet = null
|
clientSheet = null
|
||||||
tree.pathTo(id)?.let { server.expand(it) }
|
tree.pathTo(id)?.let { server.expand(it) }
|
||||||
showChat = false
|
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]. */
|
/** Says a channel is being picked for [name] to move to, until it is or [onCancel]. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun MovePrompt(name: String, onCancel: () -> Unit) {
|
private fun MovePrompt(name: String, onCancel: () -> Unit) {
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ object StatusColors {
|
|||||||
@Composable
|
@Composable
|
||||||
fun linkColor(): Color = if (isSystemInDarkTheme()) Color(0xFF7AB4FF) else Color(0xFF1A62D0)
|
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. */
|
/** The system's own (wallpaper) colours, which every supported Android version offers. */
|
||||||
@Composable
|
@Composable
|
||||||
fun Ts3Theme(content: @Composable () -> Unit) {
|
fun Ts3Theme(content: @Composable () -> Unit) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ package com.ts3client.ui;
|
|||||||
import com.ts3client.contacts.Contact;
|
import com.ts3client.contacts.Contact;
|
||||||
import com.ts3client.contacts.ContactStore;
|
import com.ts3client.contacts.ContactStore;
|
||||||
import com.ts3client.net.ChannelNode;
|
import com.ts3client.net.ChannelNode;
|
||||||
import com.ts3client.net.Spacers;
|
|
||||||
import com.ts3client.net.ClientEntry;
|
import com.ts3client.net.ClientEntry;
|
||||||
|
import com.ts3client.net.TreeMatch;
|
||||||
import com.ts3client.text.TextQuery;
|
import com.ts3client.text.TextQuery;
|
||||||
|
|
||||||
import javax.swing.Icon;
|
import javax.swing.Icon;
|
||||||
@@ -102,14 +102,9 @@ final class ServerTreeSearch implements FindBar.Listener {
|
|||||||
if (node instanceof ClientEntry c) {
|
if (node instanceof ClientEntry c) {
|
||||||
if (searchChannels) return false;
|
if (searchChannels) return false;
|
||||||
Contact contact = contacts.find(c.uniqueId);
|
Contact contact = contacts.find(c.uniqueId);
|
||||||
return query.matches(c.nickname) || (contact != null && query.matches(contact.displayName(c.nickname)));
|
return TreeMatch.client(query, c.nickname, contact != null ? contact.displayName(c.nickname) : null);
|
||||||
}
|
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
if (node instanceof ChannelNode ch) return searchChannels && TreeMatch.channel(query, ch.name);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user