diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/common/NavigatorList.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/common/NavigatorList.kt index 33ce498c..740d1358 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/common/NavigatorList.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/common/NavigatorList.kt @@ -70,13 +70,21 @@ interface NavigatorListState, A : ContextMenuAction fun submit(index: Int) fun updateProject(project: Project) fun calculateResult(): Pair> - 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() diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesEditor.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesEditor.kt index d5dabc8e..757cb2ba 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesEditor.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesEditor.kt @@ -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 @@ -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 @@ -158,6 +161,7 @@ fun PreferencesEditor( KeymapItemEditConflictDialog(it) } FilePicker(state) + StringListFilePicker(state) } @Composable @@ -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) }, + ) + } + } } } } @@ -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 -> diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesEditorState.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesEditorState.kt index b814609d..8ac41c7f 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesEditorState.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesEditorState.kt @@ -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 = listOf("txt") + } + @Immutable sealed class LaunchArgs(val page: PreferencesPage) { @Immutable diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesItem.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesItem.kt index 7ea69bac..03d2fe59 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesItem.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesItem.kt @@ -156,6 +156,11 @@ sealed class PreferencesItem( select: (AppConf) -> List, update: AppConf.(List) -> 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>( title, description, diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesPages.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesPages.kt index f27cd08d..995393b4 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesPages.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/dialog/preferences/PreferencesPages.kt @@ -749,6 +749,7 @@ object PreferencesPages { defaultValue = listOf(), select = { it.entryNamePresets }, update = { copy(entryNamePresets = it) }, + importExportEnabled = true, ) } } @@ -1424,6 +1425,7 @@ private class PreferencesItemContext

( select: (P) -> List, update: P.(List) -> P, enabled: (P) -> Boolean = { true }, + importExportEnabled: Boolean = false, ) = builder.item( PreferencesItem.StringListInput( title = title, @@ -1434,6 +1436,7 @@ private class PreferencesItemContext

( select = selectWithContext(select), update = updateWithContext(update), enabled = selectWithContext(enabled), + importExportEnabled = importExportEnabled, ), ) diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/editor/EntryList.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/editor/EntryList.kt index 21940f42..46a0622c 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/editor/EntryList.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/editor/EntryList.kt @@ -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 = { diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/editor/ModuleList.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/editor/ModuleList.kt index 371c6f2b..b8f9ae82 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/editor/ModuleList.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/editor/ModuleList.kt @@ -92,7 +92,7 @@ fun ModuleList( text = state.searchText, onTextChange = { state.searchText = it - state.updateSearch() + state.updateSearch(selectFirst = true) }, focusRequester = focusRequester, onFocusedChanged = { diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/Strings.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/Strings.kt index dd3e5fa3..6df94307 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/Strings.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/Strings.kt @@ -409,6 +409,14 @@ enum class Strings { PreferencesEditorEntryNamePresetsDescription, PreferencesStringListEmptyPlaceholder, PreferencesStringListNewItemPlaceholder, + PreferencesStringListImport, + PreferencesStringListExport, + PreferencesStringListImportDialogTitle, + PreferencesStringListExportDialogTitle, + PreferencesStringListImportSuccess, + PreferencesStringListImportFailure, + PreferencesStringListExportSuccess, + PreferencesStringListExportFailure, PreferencesEditorLockedDrag, PreferencesEditorLockedDragDescription, PreferencesEditorLockedDragUseLabeler, diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsChineseSimplified.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsChineseSimplified.kt index 6f842aed..dec47402 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsChineseSimplified.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsChineseSimplified.kt @@ -442,6 +442,14 @@ fun Strings.zhHans(): String? = when (this) { PreferencesEditorEntryNamePresetsDescription -> "预设的条目名称,可以在输入条目名称的对话框中(例如重命名或剪切条目时)快速使用。" PreferencesStringListEmptyPlaceholder -> "没有项目" PreferencesStringListNewItemPlaceholder -> "新的项目..." + PreferencesStringListImport -> "从文件导入" + PreferencesStringListExport -> "导出到文件" + PreferencesStringListImportDialogTitle -> "从文件导入" + PreferencesStringListExportDialogTitle -> "导出到文件" + PreferencesStringListImportSuccess -> "已成功导入项目。" + PreferencesStringListImportFailure -> "导入所选文件失败。" + PreferencesStringListExportSuccess -> "已成功导出项目。" + PreferencesStringListExportFailure -> "导出到所选文件失败。" PreferencesEditorLockedDrag -> "锁定拖动" PreferencesEditorLockedDragDescription -> "选择启用锁定拖动的条件。" + diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsEnglish.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsEnglish.kt index 79a1aea3..8b8ad269 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsEnglish.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsEnglish.kt @@ -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 " + diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsJapanese.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsJapanese.kt index 01e1e3d1..c1a5ed46 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsJapanese.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsJapanese.kt @@ -469,6 +469,14 @@ fun Strings.ja(): String? = when (this) { "エントリ名を入力するダイアログ(エントリの名前変更やカット時など)で、すばやく適用できるエントリ名のプリセットです。" PreferencesStringListEmptyPlaceholder -> "項目がありません" PreferencesStringListNewItemPlaceholder -> "新しい項目..." + PreferencesStringListImport -> "ファイルからインポート" + PreferencesStringListExport -> "ファイルにエクスポート" + PreferencesStringListImportDialogTitle -> "ファイルからインポート" + PreferencesStringListExportDialogTitle -> "ファイルにエクスポート" + PreferencesStringListImportSuccess -> "項目をインポートしました。" + PreferencesStringListImportFailure -> "選択したファイルのインポートに失敗しました。" + PreferencesStringListExportSuccess -> "項目をエクスポートしました。" + PreferencesStringListExportFailure -> "選択したファイルへのエクスポートに失敗しました。" PreferencesEditorLockedDrag -> "連動ドラッグ" PreferencesEditorLockedDragDescription -> "連動ドラッグを有効にする条件を選択します。" + diff --git a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsKorean.kt b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsKorean.kt index 8e203220..34acf24a 100644 --- a/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsKorean.kt +++ b/src/jvmMain/kotlin/com/sdercolin/vlabeler/ui/string/StringsKorean.kt @@ -486,6 +486,14 @@ fun Strings.ko(): String? = when (this) { "엔트리 이름을 입력하는 대화창(엔트리 이름 변경, 자르기 등)에서 빠르게 적용할 수 있는 엔트리 이름 프리셋이에요." PreferencesStringListEmptyPlaceholder -> "항목이 없어요" PreferencesStringListNewItemPlaceholder -> "새 항목..." + PreferencesStringListImport -> "파일에서 가져오기" + PreferencesStringListExport -> "파일로 내보내기" + PreferencesStringListImportDialogTitle -> "파일에서 가져오기" + PreferencesStringListExportDialogTitle -> "파일로 내보내기" + PreferencesStringListImportSuccess -> "항목을 가져왔어요." + PreferencesStringListImportFailure -> "선택한 파일을 가져오지 못했어요." + PreferencesStringListExportSuccess -> "항목을 내보냈어요." + PreferencesStringListExportFailure -> "선택한 파일로 내보내지 못했어요." PreferencesEditorLockedDrag -> "고정 드래그" PreferencesEditorLockedDragDescription -> "주요 마커를 움직일 때, 고정 드래그의 방식을 선택합니다.\n" + diff --git a/src/jvmTest/kotlin/ui/common/NavigatorListStateTest.kt b/src/jvmTest/kotlin/ui/common/NavigatorListStateTest.kt new file mode 100644 index 00000000..33872666 --- /dev/null +++ b/src/jvmTest/kotlin/ui/common/NavigatorListStateTest.kt @@ -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 { + @Composable + override fun getContextMenuActions(): List = 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, + private val filterActive: Boolean = false, + ) : NavigatorListState { + override var selectedIndex: Int? = null + override var isFiltered: Boolean = false + override var searchResult: List = 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> = + 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) + } +}