Skip to content

Commit b425bfe

Browse files
Enhance search in document feature (#512)
* Add new icon and text * Highlight the current result based on last position and control search index * Add results count and chevrons to iterate from results
1 parent fc6a567 commit b425bfe

File tree

9 files changed

+168
-17
lines changed

9 files changed

+168
-17
lines changed

application/core/resources/src/commonMain/composeResources/values-en/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
<string name="enter_without_register">Enter without register</string>
3838
<string name="register">Register</string>
3939
<string name="search">Search</string>
40+
<string name="search_no_results">0 results</string>
4041
<string name="home">Home</string>
4142
<string name="favorites">Favorites</string>
4243
<string name="settings">Settings</string>

application/core/resources/src/commonMain/composeResources/values-pt/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
<string name="enter_without_register">Entrar without register</string>
3838
<string name="register">Registrar</string>
3939
<string name="search">Pesquisar</string>
40+
<string name="search_no_results">0 resultados</string>
4041
<string name="home">Home</string>
4142
<string name="favorites">Favoritos</string>
4243
<string name="settings">Configurações</string>

application/core/resources/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
<string name="enter_without_register">Enter without register</string>
3838
<string name="register">Register</string>
3939
<string name="search">Search</string>
40+
<string name="search_no_results">0 results</string>
4041
<string name="home">Home</string>
4142
<string name="favorites">Favorites</string>
4243
<string name="settings">Settings</string>

application/core/resources/src/commonMain/kotlin/io/writeopia/resources/WrStrings.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import writeopia.application.core.resources.generated.resources.recent
8484
import writeopia.application.core.resources.generated.resources.repeat_password
8585
import writeopia.application.core.resources.generated.resources.retry
8686
import writeopia.application.core.resources.generated.resources.search
87+
import writeopia.application.core.resources.generated.resources.search_no_results
8788
import writeopia.application.core.resources.generated.resources.settings
8889
import writeopia.application.core.resources.generated.resources.sign_in
8990
import writeopia.application.core.resources.generated.resources.sign_in_account
@@ -112,6 +113,9 @@ object WrStrings {
112113
@Composable
113114
fun search() = stringResource(Res.string.search)
114115

116+
@Composable
117+
fun searchNoResults() = stringResource(Res.string.search_no_results)
118+
115119
@Composable
116120
fun home() = stringResource(Res.string.home)
117121

application/core/utils/src/commonMain/kotlin/io/writeopia/common/utils/icons/WrIcons.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import io.writeopia.common.utils.icons.all.CalendarArrowDown
2020
import io.writeopia.common.utils.icons.all.ChartScatter
2121
import io.writeopia.common.utils.icons.all.ChevronDown
2222
import io.writeopia.common.utils.icons.all.ChevronRight
23+
import io.writeopia.common.utils.icons.all.ChevronUp
2324
import io.writeopia.common.utils.icons.all.CircleArrowLeft
2425
import io.writeopia.common.utils.icons.all.CircleArrowRight
2526
import io.writeopia.common.utils.icons.all.CirclePlus
@@ -131,6 +132,8 @@ object WrIcons {
131132

132133
val smallArrowDown: ImageVector = ChevronDown
133134

135+
val smallArrowUp: ImageVector = ChevronUp
136+
134137
val undo: ImageVector = Undo2
135138

136139
val redo: ImageVector = Redo2
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package io.writeopia.common.utils.icons.all
2+
/*
3+
* Converted using https://composables.com/svgtocompose
4+
*/
5+
6+
import androidx.compose.ui.graphics.Color
7+
import androidx.compose.ui.graphics.PathFillType
8+
import androidx.compose.ui.graphics.SolidColor
9+
import androidx.compose.ui.graphics.StrokeCap
10+
import androidx.compose.ui.graphics.StrokeJoin
11+
import androidx.compose.ui.graphics.vector.ImageVector
12+
import androidx.compose.ui.graphics.vector.path
13+
import androidx.compose.ui.unit.dp
14+
15+
internal val ChevronUp: ImageVector
16+
get() {
17+
if (_ChevronUp != null) {
18+
return _ChevronUp!!
19+
}
20+
_ChevronUp = ImageVector.Builder(
21+
name = "io.writeopia.common.utils.icons.all.getChevronUp",
22+
defaultWidth = 24.dp,
23+
defaultHeight = 24.dp,
24+
viewportWidth = 24f,
25+
viewportHeight = 24f
26+
).apply {
27+
path(
28+
fill = null,
29+
fillAlpha = 1.0f,
30+
stroke = SolidColor(Color(0xFF000000)),
31+
strokeAlpha = 1.0f,
32+
strokeLineWidth = 2f,
33+
strokeLineCap = StrokeCap.Round,
34+
strokeLineJoin = StrokeJoin.Round,
35+
strokeLineMiter = 1.0f,
36+
pathFillType = PathFillType.NonZero
37+
) {
38+
moveTo(18f, 15f)
39+
lineToRelative(-6f, -6f)
40+
lineToRelative(-6f, 6f)
41+
}
42+
}.build()
43+
return _ChevronUp!!
44+
}
45+
46+
private var _ChevronUp: ImageVector? = null

application/features/editor/src/commonMain/kotlin/io/writeopia/editor/features/editor/ui/desktop/DesktopNoteEditorScreen.kt

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import androidx.compose.foundation.layout.defaultMinSize
1414
import androidx.compose.foundation.layout.fillMaxSize
1515
import androidx.compose.foundation.layout.padding
1616
import androidx.compose.foundation.layout.size
17+
import androidx.compose.foundation.layout.width
1718
import androidx.compose.foundation.shape.CircleShape
1819
import androidx.compose.foundation.text.BasicTextField
1920
import androidx.compose.material.icons.Icons
2021
import androidx.compose.material.icons.outlined.Lock
2122
import androidx.compose.material3.CircularProgressIndicator
2223
import androidx.compose.material3.Icon
2324
import androidx.compose.material3.MaterialTheme
25+
import androidx.compose.material3.Text
2426
import androidx.compose.runtime.Composable
2527
import androidx.compose.runtime.LaunchedEffect
2628
import androidx.compose.runtime.collectAsState
@@ -34,13 +36,15 @@ import androidx.compose.ui.draw.clip
3436
import androidx.compose.ui.focus.FocusRequester
3537
import androidx.compose.ui.focus.focusRequester
3638
import androidx.compose.ui.graphics.SolidColor
39+
import androidx.compose.ui.text.style.TextAlign
3740
import androidx.compose.ui.unit.dp
3841
import io.writeopia.common.utils.icons.WrIcons
3942
import io.writeopia.commonui.dialogs.confirmation.DeleteConfirmationDialog
4043
import io.writeopia.editor.features.editor.ui.desktop.edit.menu.SideEditorOptions
4144
import io.writeopia.editor.features.editor.ui.folders.FolderSelectionDialog
4245
import io.writeopia.editor.features.editor.viewmodel.NoteEditorViewModel
4346
import io.writeopia.editor.features.editor.viewmodel.SideMenuTab
47+
import io.writeopia.resources.WrStrings
4448
import io.writeopia.theme.WriteopiaTheme
4549
import io.writeopia.ui.drawer.factory.DrawersFactory
4650

@@ -100,6 +104,8 @@ fun DesktopNoteEditorScreen(
100104

101105
val textState by noteEditorViewModel.searchText.collectAsState()
102106
val showSearch by noteEditorViewModel.showSearchState.collectAsState()
107+
val currentResultIndex by noteEditorViewModel.currentSearchIndexState.collectAsState()
108+
val totalResults by noteEditorViewModel.totalSearchResultsState.collectAsState()
103109
val shape = MaterialTheme.shapes.medium
104110

105111
AnimatedVisibility(
@@ -110,21 +116,55 @@ fun DesktopNoteEditorScreen(
110116
Box(modifier = Modifier.padding(6.dp)) {
111117
val focusRequester = remember { FocusRequester() }
112118

113-
BasicTextField(
114-
value = textState,
115-
onValueChange = noteEditorViewModel::searchInDocument,
116-
modifier = Modifier.defaultMinSize(minWidth = 160.dp)
117-
.focusRequester(focusRequester)
119+
Row(
120+
modifier = Modifier
118121
.padding(12.dp)
119122
.background(WriteopiaTheme.colorScheme.cardBg, shape)
120-
.border(1.dp, MaterialTheme.colorScheme.outline, shape)
121-
.padding(8.dp),
122-
singleLine = true,
123-
textStyle = MaterialTheme.typography.bodySmall.copy(
124-
color = MaterialTheme.colorScheme.onBackground
125-
),
126-
cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
127-
)
123+
.border(1.dp, MaterialTheme.colorScheme.outline, shape),
124+
verticalAlignment = Alignment.CenterVertically
125+
) {
126+
BasicTextField(
127+
value = textState,
128+
onValueChange = noteEditorViewModel::searchInDocument,
129+
modifier = Modifier.defaultMinSize(minWidth = 160.dp)
130+
.focusRequester(focusRequester)
131+
.padding(8.dp),
132+
singleLine = true,
133+
textStyle = MaterialTheme.typography.bodySmall.copy(
134+
color = MaterialTheme.colorScheme.onBackground
135+
),
136+
cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
137+
)
138+
139+
Text(
140+
modifier = Modifier.padding(4.dp).width(100.dp),
141+
text = if (totalResults == 0) WrStrings.searchNoResults() else "${currentResultIndex + 1}/$totalResults",
142+
textAlign = TextAlign.Right,
143+
style = MaterialTheme.typography.bodySmall.copy(
144+
color = MaterialTheme.colorScheme.onBackground
145+
)
146+
)
147+
148+
Icon(
149+
imageVector = WrIcons.smallArrowUp,
150+
contentDescription = "Previous result",
151+
tint = MaterialTheme.colorScheme.onBackground,
152+
modifier = Modifier
153+
.size(32.dp)
154+
.clickable(onClick = noteEditorViewModel::previousSearchResult)
155+
.padding(4.dp)
156+
)
157+
158+
Icon(
159+
imageVector = WrIcons.smallArrowDown,
160+
contentDescription = "Next result",
161+
tint = MaterialTheme.colorScheme.onBackground,
162+
modifier = Modifier
163+
.size(32.dp)
164+
.clickable(onClick = noteEditorViewModel::nextSearchResult)
165+
.padding(4.dp)
166+
)
167+
}
128168

129169
Icon(
130170
imageVector = WrIcons.close,

application/features/editor/src/commonMain/kotlin/io/writeopia/editor/features/editor/viewmodel/NoteEditorKmpViewModel.kt

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import kotlinx.coroutines.flow.StateFlow
5858
import kotlinx.coroutines.flow.asStateFlow
5959
import kotlinx.coroutines.flow.combine
6060
import kotlinx.coroutines.flow.filterNotNull
61+
import kotlinx.coroutines.flow.first
6162
import kotlinx.coroutines.flow.flatMapConcat
6263
import kotlinx.coroutines.flow.flatMapLatest
6364
import kotlinx.coroutines.flow.map
@@ -142,9 +143,13 @@ class NoteEditorKmpViewModel(
142143

143144
private val _showSearch = MutableStateFlow(false)
144145
private val _searchText = MutableStateFlow("")
146+
private val _currentSearchIndex = MutableStateFlow(0)
147+
private val _totalSearchResults = MutableStateFlow(0)
145148

146149
override val showSearchState: StateFlow<Boolean> = _showSearch.asStateFlow()
147150
override val searchText: StateFlow<String> = _searchText.asStateFlow()
151+
override val currentSearchIndexState: StateFlow<Int> = _currentSearchIndex.asStateFlow()
152+
override val totalSearchResultsState: StateFlow<Int> = _totalSearchResults.asStateFlow()
148153

149154
private val hasLinesSelection = writeopiaManager.onEditPositions
150155
.map { it.isNotEmpty() }
@@ -272,12 +277,15 @@ class NoteEditorKmpViewModel(
272277
writeopiaManager.toDraw,
273278
findsOfSearch,
274279
searchText,
275-
_showSearch
276-
) { drawState, finds, query, showSearch ->
280+
_showSearch,
281+
_currentSearchIndex
282+
) { drawState, finds, query, showSearch, currentSearchIndex ->
277283
if (finds.isEmpty() && showSearch) return@combine drawState.copy(focus = null)
278284
if (finds.isEmpty()) return@combine drawState
279285

280286
val mutableStories = drawState.stories.toMutableList()
287+
val activeFindPosition = if (finds.size > currentSearchIndex) finds.elementAt(currentSearchIndex) else null
288+
_totalSearchResults.value = finds.size
281289

282290
finds.forEach { position ->
283291
val realPosition = minOf(position * 2, mutableStories.lastIndex)
@@ -286,7 +294,12 @@ class NoteEditorKmpViewModel(
286294

287295
val findSpans = FindInText.findInText(story.text ?: "", query)
288296
.map { (start, end) ->
289-
SpanInfo.create(start, end, Span.HIGHLIGHT_YELLOW)
297+
val span = if (position == activeFindPosition) {
298+
Span.HIGHLIGHT_GREEN
299+
} else {
300+
Span.HIGHLIGHT_YELLOW
301+
}
302+
SpanInfo.create(start, end, span)
290303
}
291304

292305
mutableStories[realPosition] =
@@ -718,11 +731,45 @@ class NoteEditorKmpViewModel(
718731
_showSearch.value = false
719732
delay(100)
720733
_searchText.value = ""
734+
_currentSearchIndex.value = 0
721735
}
722736
}
723737

724738
override fun searchInDocument(query: String) {
725-
_searchText.value = query
739+
viewModelScope.launch {
740+
_searchText.value = query
741+
val finds = findsOfSearch.first().toList().sorted()
742+
if (finds.isEmpty()) {
743+
_currentSearchIndex.value = 0
744+
_totalSearchResults.value = 0
745+
return@launch
746+
}
747+
748+
val currentPosition = writeopiaManager.currentStory.value.focus ?: writeopiaManager.currentStory.value.selection.position
749+
val newIndex = finds.indexOfFirst { it >= currentPosition }
750+
751+
_currentSearchIndex.value = if (newIndex != -1) newIndex else 0
752+
}
753+
}
754+
755+
override fun previousSearchResult() {
756+
viewModelScope.launch {
757+
val total = findsOfSearch.first().size
758+
if (total == 0) return@launch
759+
760+
val currentIndex = _currentSearchIndex.value
761+
_currentSearchIndex.value = (currentIndex - 1 + total) % total
762+
}
763+
}
764+
765+
override fun nextSearchResult() {
766+
viewModelScope.launch {
767+
val total = findsOfSearch.first().size
768+
if (total == 0) return@launch
769+
770+
val currentIndex = _currentSearchIndex.value
771+
_currentSearchIndex.value = (currentIndex + 1) % total
772+
}
726773
}
727774

728775
override fun titleClick(tag: Tag) {

application/features/editor/src/commonMain/kotlin/io/writeopia/editor/features/editor/viewmodel/NoteEditorViewModel.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ interface NoteEditorViewModel : BackstackInform, BackstackHandler {
5252

5353
val searchText: StateFlow<String>
5454

55+
val currentSearchIndexState: StateFlow<Int>
56+
57+
val totalSearchResultsState: StateFlow<Int>
58+
5559
val hasSelectedLines: StateFlow<Boolean>
5660

5761
val selectionMetadataState: StateFlow<Set<SelectionMetadata>>
@@ -66,6 +70,10 @@ interface NoteEditorViewModel : BackstackInform, BackstackHandler {
6670

6771
fun searchInDocument(query: String)
6872

73+
fun previousSearchResult()
74+
75+
fun nextSearchResult()
76+
6977
fun toggleEditable()
7078

7179
fun deleteSelection()

0 commit comments

Comments
 (0)