Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,21 @@ interface NavigatorListState<S : ContextMenuSubject<A>, A : ContextMenuAction<A>
fun submit(index: Int)
fun updateProject(project: Project)
fun calculateResult(): Pair<Boolean, List<S>>
fun updateSearch() {

/**
* Recalculates the displayed results and updates the highlighted item.
*
* @param selectFirst when true (the user is actively typing a search query), the first result is highlighted so
* that pressing Enter jumps to the top match. When false (the list is being opened or the current entry/module
* changed via navigation), the current entry/module is highlighted instead, giving a useful pre-selection.
*/
fun updateSearch(selectFirst: Boolean = false) {
val (active, newResults) = calculateResult()
searchResult = newResults
selectedIndex = if (hasFocus) {
if (newResults.isNotEmpty()) 0 else null
} else {
newResults.indexOfFirst { it.index == currentIndex }.takeIf { it >= 0 }
selectedIndex = when {
newResults.isEmpty() -> null
selectFirst -> 0
else -> newResults.indexOfFirst { it.index == currentIndex }.takeIf { it >= 0 }
}
isFiltered = active
multiSelectClear()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Settings
Expand Down Expand Up @@ -78,6 +80,7 @@ import com.sdercolin.vlabeler.ui.common.SearchBar
import com.sdercolin.vlabeler.ui.common.SelectionBox
import com.sdercolin.vlabeler.ui.common.SingleClickableText
import com.sdercolin.vlabeler.ui.common.TextInputBox
import com.sdercolin.vlabeler.ui.common.WithTooltip
import com.sdercolin.vlabeler.ui.common.plainClickable
import com.sdercolin.vlabeler.ui.dialog.ColorPickerArgs
import com.sdercolin.vlabeler.ui.dialog.ColorPickerDialog
Expand Down Expand Up @@ -158,6 +161,7 @@ fun PreferencesEditor(
KeymapItemEditConflictDialog(it)
}
FilePicker(state)
StringListFilePicker(state)
}

@Composable
Expand Down Expand Up @@ -546,6 +550,28 @@ private fun StringListInputItem(item: PreferencesItem.StringListInput, state: Pr
contentDescription = null,
tint = MaterialTheme.colors.onSurface.runIf(addEnabled.not()) { copy(alpha = 0.2f) },
)
if (item.importExportEnabled) {
Spacer(Modifier.weight(1f))
WithTooltip(string(Strings.PreferencesStringListImport)) {
Icon(
modifier = Modifier.size(18.dp)
.clickable(enabled = enabled) { state.requestStringListImport(item) },
imageVector = Icons.Default.FileDownload,
contentDescription = null,
tint = MaterialTheme.colors.onSurface.runIf(enabled.not()) { copy(alpha = 0.2f) },
)
}
val exportEnabled = enabled && values.isNotEmpty()
WithTooltip(string(Strings.PreferencesStringListExport)) {
Icon(
modifier = Modifier.size(18.dp)
.clickable(enabled = exportEnabled) { state.requestStringListExport(item) },
imageVector = Icons.Default.FileUpload,
contentDescription = null,
tint = MaterialTheme.colors.onSurface.runIf(exportEnabled.not()) { copy(alpha = 0.2f) },
)
}
}
}
}
}
Expand Down Expand Up @@ -751,6 +777,32 @@ private fun ButtonBar(
}
}

@Composable
private fun StringListFilePicker(state: PreferencesEditorState) {
state.stringListFilePicker?.let { picker ->
val title = string(picker.title)
if (picker.writeMode) {
SaveFileDialog(
title = title,
extensions = picker.extensions,
initialFileName = picker.initialFileName,
onCloseRequest = { parent, name ->
state.handleStringListFilePickerResult(picker, parent, name)
},
)
} else {
OpenFileDialog(
title = title,
extensions = picker.extensions,
initialFileName = picker.initialFileName,
onCloseRequest = { parent, name ->
state.handleStringListFilePickerResult(picker, parent, name)
},
)
}
}
}

@Composable
private fun FilePicker(state: PreferencesEditorState) {
state.currentFilePicker?.let { picker ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,67 @@ class PreferencesEditorState(
),
}

var stringListFilePicker: StringListFilePicker? by mutableStateOf(null)
private set

fun requestStringListImport(item: PreferencesItem.StringListInput) {
stringListFilePicker = StringListFilePicker(
item = item,
writeMode = false,
title = Strings.PreferencesStringListImportDialogTitle,
initialFileName = null,
)
}

fun requestStringListExport(item: PreferencesItem.StringListInput) {
stringListFilePicker = StringListFilePicker(
item = item,
writeMode = true,
title = Strings.PreferencesStringListExportDialogTitle,
initialFileName = "presets.txt",
)
}

fun handleStringListFilePickerResult(
picker: StringListFilePicker,
parent: String?,
name: String?,
) {
stringListFilePicker = null
if (parent == null || name == null) return
val file = File(parent, name)
if (picker.writeMode) {
runCatching { file.writeText(picker.item.select(conf).joinToString("\n")) }
.onSuccess { showSnackbar(stringStatic(Strings.PreferencesStringListExportSuccess)) }
.onFailure {
showSnackbar(stringStatic(Strings.PreferencesStringListExportFailure))
Log.error(it)
}
} else {
runCatching {
file.readText().lines().map { it.trim() }.filter { it.isNotEmpty() }.distinct()
}
.onSuccess {
update(picker.item, it)
showSnackbar(stringStatic(Strings.PreferencesStringListImportSuccess))
}
.onFailure {
showSnackbar(stringStatic(Strings.PreferencesStringListImportFailure))
Log.error(it)
}
}
}

@Immutable
data class StringListFilePicker(
val item: PreferencesItem.StringListInput,
val writeMode: Boolean,
val title: Strings,
val initialFileName: String?,
) {
val extensions: List<String> = listOf("txt")
}

@Immutable
sealed class LaunchArgs(val page: PreferencesPage) {
@Immutable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ sealed class PreferencesItem(
select: (AppConf) -> List<String>,
update: AppConf.(List<String>) -> AppConf,
enabled: (AppConf) -> Boolean,
/**
* When true, import/export buttons are shown, allowing the list to be read from / written to a plain-text file
* with one item per line.
*/
val importExportEnabled: Boolean = false,
) : Valued<List<String>>(
title,
description,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@ object PreferencesPages {
defaultValue = listOf(),
select = { it.entryNamePresets },
update = { copy(entryNamePresets = it) },
importExportEnabled = true,
)
}
}
Expand Down Expand Up @@ -1424,6 +1425,7 @@ private class PreferencesItemContext<P>(
select: (P) -> List<String>,
update: P.(List<String>) -> P,
enabled: (P) -> Boolean = { true },
importExportEnabled: Boolean = false,
) = builder.item(
PreferencesItem.StringListInput(
title = title,
Expand All @@ -1434,6 +1436,7 @@ private class PreferencesItemContext<P>(
select = selectWithContext(select),
update = updateWithContext(update),
enabled = selectWithContext(enabled),
importExportEnabled = importExportEnabled,
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ fun EntryList(
text = filterState.filter.searchText,
onTextChange = {
filterState.editFilter { copy(searchText = it) }
state.updateSearch()
state.updateSearch(selectFirst = true)
},
focusRequester = focusRequester,
onFocusedChanged = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ fun ModuleList(
text = state.searchText,
onTextChange = {
state.searchText = it
state.updateSearch()
state.updateSearch(selectFirst = true)
},
focusRequester = focusRequester,
onFocusedChanged = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,14 @@ enum class Strings {
PreferencesEditorEntryNamePresetsDescription,
PreferencesStringListEmptyPlaceholder,
PreferencesStringListNewItemPlaceholder,
PreferencesStringListImport,
PreferencesStringListExport,
PreferencesStringListImportDialogTitle,
PreferencesStringListExportDialogTitle,
PreferencesStringListImportSuccess,
PreferencesStringListImportFailure,
PreferencesStringListExportSuccess,
PreferencesStringListExportFailure,
PreferencesEditorLockedDrag,
PreferencesEditorLockedDragDescription,
PreferencesEditorLockedDragUseLabeler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,14 @@ fun Strings.zhHans(): String? = when (this) {
PreferencesEditorEntryNamePresetsDescription -> "预设的条目名称,可以在输入条目名称的对话框中(例如重命名或剪切条目时)快速使用。"
PreferencesStringListEmptyPlaceholder -> "没有项目"
PreferencesStringListNewItemPlaceholder -> "新的项目..."
PreferencesStringListImport -> "从文件导入"
PreferencesStringListExport -> "导出到文件"
PreferencesStringListImportDialogTitle -> "从文件导入"
PreferencesStringListExportDialogTitle -> "导出到文件"
PreferencesStringListImportSuccess -> "已成功导入项目。"
PreferencesStringListImportFailure -> "导入所选文件失败。"
PreferencesStringListExportSuccess -> "已成功导出项目。"
PreferencesStringListExportFailure -> "导出到所选文件失败。"
PreferencesEditorLockedDrag -> "锁定拖动"
PreferencesEditorLockedDragDescription ->
"选择启用锁定拖动的条件。" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,14 @@ fun Strings.en(): String = when (this) {
"renaming or cutting an entry."
PreferencesStringListEmptyPlaceholder -> "No items"
PreferencesStringListNewItemPlaceholder -> "New item..."
PreferencesStringListImport -> "Import from file"
PreferencesStringListExport -> "Export to file"
PreferencesStringListImportDialogTitle -> "Import from file"
PreferencesStringListExportDialogTitle -> "Export to file"
PreferencesStringListImportSuccess -> "Successfully imported items."
PreferencesStringListImportFailure -> "Failed to import the selected file."
PreferencesStringListExportSuccess -> "Successfully exported items."
PreferencesStringListExportFailure -> "Failed to export to the selected file."
PreferencesEditorLockedDrag -> "Fixed-drag"
PreferencesEditorLockedDragDescription ->
"Select a condition to enable fixed-drag while you move " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,14 @@ fun Strings.ja(): String? = when (this) {
"エントリ名を入力するダイアログ(エントリの名前変更やカット時など)で、すばやく適用できるエントリ名のプリセットです。"
PreferencesStringListEmptyPlaceholder -> "項目がありません"
PreferencesStringListNewItemPlaceholder -> "新しい項目..."
PreferencesStringListImport -> "ファイルからインポート"
PreferencesStringListExport -> "ファイルにエクスポート"
PreferencesStringListImportDialogTitle -> "ファイルからインポート"
PreferencesStringListExportDialogTitle -> "ファイルにエクスポート"
PreferencesStringListImportSuccess -> "項目をインポートしました。"
PreferencesStringListImportFailure -> "選択したファイルのインポートに失敗しました。"
PreferencesStringListExportSuccess -> "項目をエクスポートしました。"
PreferencesStringListExportFailure -> "選択したファイルへのエクスポートに失敗しました。"
PreferencesEditorLockedDrag -> "連動ドラッグ"
PreferencesEditorLockedDragDescription ->
"連動ドラッグを有効にする条件を選択します。" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,14 @@ fun Strings.ko(): String? = when (this) {
"엔트리 이름을 입력하는 대화창(엔트리 이름 변경, 자르기 등)에서 빠르게 적용할 수 있는 엔트리 이름 프리셋이에요."
PreferencesStringListEmptyPlaceholder -> "항목이 없어요"
PreferencesStringListNewItemPlaceholder -> "새 항목..."
PreferencesStringListImport -> "파일에서 가져오기"
PreferencesStringListExport -> "파일로 내보내기"
PreferencesStringListImportDialogTitle -> "파일에서 가져오기"
PreferencesStringListExportDialogTitle -> "파일로 내보내기"
PreferencesStringListImportSuccess -> "항목을 가져왔어요."
PreferencesStringListImportFailure -> "선택한 파일을 가져오지 못했어요."
PreferencesStringListExportSuccess -> "항목을 내보냈어요."
PreferencesStringListExportFailure -> "선택한 파일로 내보내지 못했어요."
PreferencesEditorLockedDrag -> "고정 드래그"
PreferencesEditorLockedDragDescription ->
"주요 마커를 움직일 때, 고정 드래그의 방식을 선택합니다.\n" +
Expand Down
79 changes: 79 additions & 0 deletions src/jvmTest/kotlin/ui/common/NavigatorListStateTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package ui.common

import androidx.compose.runtime.Composable
import com.sdercolin.vlabeler.model.LabelerConf
import com.sdercolin.vlabeler.model.Project
import com.sdercolin.vlabeler.ui.common.ContextMenuSubject
import com.sdercolin.vlabeler.ui.common.NavigatorListState
import com.sdercolin.vlabeler.ui.common.NoOpContextMenuAction
import testutil.TestLabelers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class NavigatorListStateTest {

private class FakeItem(override val index: Int) : ContextMenuSubject<NoOpContextMenuAction> {
@Composable
override fun getContextMenuActions(): List<NoOpContextMenuAction> = emptyList()
}

/**
* Minimal [NavigatorListState] backed by plain vars so the pure selection logic in [updateSearch] can be
* exercised without any Compose/Project machinery.
*/
private class FakeNavigatorListState(
override val currentIndex: Int,
private val filteredIndexes: List<Int>,
private val filterActive: Boolean = false,
) : NavigatorListState<FakeItem, NoOpContextMenuAction> {
override var selectedIndex: Int? = null
override var isFiltered: Boolean = false
override var searchResult: List<FakeItem> = emptyList()
override var hasFocus: Boolean = false
override val labelerConf: LabelerConf = TestLabelers.audacity

override fun submit(index: Int) {}
override fun updateProject(project: Project) {}
override fun calculateResult(): Pair<Boolean, List<FakeItem>> =
filterActive to filteredIndexes.map { FakeItem(it) }
}

@Test
fun `opening the list pre-selects the current item`() {
val state = FakeNavigatorListState(currentIndex = 3, filteredIndexes = (0..4).toList())
state.updateSearch()
// selectedIndex is a row index into searchResult; the current item (index 3) sits at row 3.
assertEquals(3, state.selectedIndex)
}

@Test
fun `pre-selection follows current item even when the search bar is focused`() {
// Regression: the go-to dialog auto-focuses its search bar, which used to force selection to the first row.
val state = FakeNavigatorListState(currentIndex = 2, filteredIndexes = (0..4).toList())
state.hasFocus = true
state.updateSearch()
assertEquals(2, state.selectedIndex)
}

@Test
fun `typing a query selects the first result`() {
val state = FakeNavigatorListState(currentIndex = 3, filteredIndexes = listOf(1, 3, 4), filterActive = true)
state.updateSearch(selectFirst = true)
assertEquals(0, state.selectedIndex)
}

@Test
fun `no selection when the current item is filtered out`() {
val state = FakeNavigatorListState(currentIndex = 3, filteredIndexes = listOf(0, 1, 2), filterActive = true)
state.updateSearch()
assertNull(state.selectedIndex)
}

@Test
fun `no selection when there are no results`() {
val state = FakeNavigatorListState(currentIndex = 0, filteredIndexes = emptyList(), filterActive = true)
state.updateSearch(selectFirst = true)
assertNull(state.selectedIndex)
}
}
Loading