Skip to content

Commit a0cf75e

Browse files
committed
Changes on PR feedback
- Fix scrolling glitches - larger reader window overlapping between scrolls
1 parent bc401d9 commit a0cf75e

File tree

4 files changed

+128
-47
lines changed

4 files changed

+128
-47
lines changed

app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -680,11 +680,32 @@ private void observeWindowContent() {
680680
result -> {
681681
if (result == null) return;
682682

683-
// Determine an anchor: find the text line near the middle of the current viewport
683+
// Capture the currently visible text as an anchor before replacing content
684+
String anchorText = null;
684685
int oldScrollY = scrollView.getScrollY();
686+
int viewportHeight = scrollView.getHeight();
687+
688+
Layout oldLayout = mainTextView.getLayout();
689+
if (oldLayout != null && mainTextView.getText() != null) {
690+
// Find the line at the middle of the viewport
691+
int anchorY = oldScrollY + viewportHeight / 2;
692+
int anchorLine = oldLayout.getLineForVertical(anchorY);
693+
694+
if (anchorLine >= 0 && anchorLine < oldLayout.getLineCount()) {
695+
int lineStart = oldLayout.getLineStart(anchorLine);
696+
int lineEnd = oldLayout.getLineEnd(anchorLine);
697+
if (lineStart < lineEnd && lineEnd <= mainTextView.getText().length()) {
698+
// Get a distinctive snippet (up to 80 chars) from this line
699+
int snippetEnd = Math.min(lineEnd, lineStart + 80);
700+
anchorText =
701+
mainTextView.getText().subSequence(lineStart, snippetEnd).toString();
702+
}
703+
}
704+
}
685705

686706
// Replace text (TextWatcher will fire but windowed-mode guard skips modification
687707
// tracking)
708+
final String savedAnchorText = anchorText;
688709
mainTextView.setText(result.getText());
689710

690711
// Adjust scroll position for visual continuity
@@ -693,28 +714,53 @@ private void observeWindowContent() {
693714
Layout newLayout = mainTextView.getLayout();
694715
if (newLayout == null) return;
695716

696-
// Infer direction from old scroll position
697-
int viewportHeight = scrollView.getHeight();
698-
if (oldScrollY > viewportHeight / 2) {
699-
// Was scrolling down → new content has overlap at the top → scroll to top
700-
// area
701-
// The overlap is ~50% of the window, so position at roughly 25% down
702-
int targetLine = newLayout.getLineCount() / 4;
703-
int targetY = newLayout.getLineTop(targetLine);
704-
scrollView.scrollTo(0, targetY);
717+
int targetY;
718+
719+
// Try to find the anchor text in the new content
720+
if (savedAnchorText != null && mainTextView.getText() != null) {
721+
String newText = mainTextView.getText().toString();
722+
int anchorIndex = newText.indexOf(savedAnchorText);
723+
724+
if (anchorIndex >= 0) {
725+
// Found anchor - restore position so anchor is at middle of viewport
726+
int anchorLine = newLayout.getLineForOffset(anchorIndex);
727+
int anchorLineTop = newLayout.getLineTop(anchorLine);
728+
targetY = Math.max(0, anchorLineTop - viewportHeight / 2);
729+
} else {
730+
// Anchor not found - use fallback positioning
731+
targetY =
732+
inferScrollPositionFallback(newLayout, oldScrollY, viewportHeight);
733+
}
705734
} else {
706-
// Was scrolling up → new content has overlap at the bottom → scroll to bottom
707-
// area
708-
int targetLine = (newLayout.getLineCount() * 3) / 4;
709-
int targetY = newLayout.getLineTop(targetLine);
710-
scrollView.scrollTo(0, Math.max(0, targetY - viewportHeight));
735+
// No anchor - use fallback positioning
736+
targetY = inferScrollPositionFallback(newLayout, oldScrollY, viewportHeight);
711737
}
712738

739+
scrollView.scrollTo(0, targetY);
713740
invalidateOptionsMenu();
714741
});
715742
});
716743
}
717744

745+
/**
746+
* Fallback method to infer scroll position when anchor text is not found. Uses the old scroll
747+
* position to determine if user was scrolling up or down.
748+
*/
749+
private int inferScrollPositionFallback(Layout newLayout, int oldScrollY, int viewportHeight) {
750+
// Infer direction from old scroll position
751+
if (oldScrollY > viewportHeight / 2) {
752+
// Was scrolling down → new content has overlap at the top
753+
// Position at roughly 1/3 down to provide smooth downward scrolling
754+
int targetLine = newLayout.getLineCount() / 3;
755+
return newLayout.getLineTop(targetLine);
756+
} else {
757+
// Was scrolling up → new content has overlap at the bottom
758+
// Position at roughly 2/3 down to provide smooth upward scrolling
759+
int targetLine = (newLayout.getLineCount() * 2) / 3;
760+
return Math.max(0, newLayout.getLineTop(targetLine) - viewportHeight / 2);
761+
}
762+
}
763+
718764
/**
719765
* Called by ReadTextFileTask after initializing windowed mode. Sets up a scroll listener that
720766
* triggers window loads when the user scrolls near the top or bottom edge.

app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,25 +103,30 @@ class TextEditorActivityViewModel : ViewModel() {
103103
/**
104104
* Loads the next or previous window of text from the file.
105105
* Debounced: if a load is already in flight, the call is ignored.
106+
*
107+
* The method attempts to maintain ~60% overlap between consecutive windows to provide
108+
* smooth scrolling. Due to line-boundary snapping, exact overlap cannot be guaranteed,
109+
* but this provides better continuity than 50% overlap.
106110
*/
107111
fun loadWindow(direction: Direction) {
108112
if (windowLoadJob?.isActive == true) return // debounce
109113
val reader = fileWindowReader ?: return
110114

111115
val windowSize = windowEndByte - windowStartByte
112-
val halfWindow = windowSize / 2
116+
// Use 40% shift (60% overlap) for smoother transitions
117+
val shiftAmount = (windowSize * 0.4).toLong()
113118

114119
val targetOffset =
115120
when (direction) {
116121
Direction.FORWARD -> {
117122
// Don't shift if already at end of file
118123
if (windowEndByte >= totalFileSize) return
119-
windowStartByte + halfWindow
124+
windowStartByte + shiftAmount
120125
}
121126
Direction.BACKWARD -> {
122127
// Don't shift if already at start of file
123128
if (windowStartByte <= 0L) return
124-
maxOf(0L, windowStartByte - halfWindow)
129+
maxOf(0L, windowStartByte - shiftAmount)
125130
}
126131
}
127132

app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModelTest.kt

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +66,25 @@ class TextEditorActivityViewModelTest {
6666

6767
private val testDispatcher = StandardTestDispatcher()
6868

69+
/**
70+
* Setup before test.
71+
*/
6972
@Before
7073
fun setUp() {
7174
Dispatchers.setMain(testDispatcher)
7275
}
7376

77+
/**
78+
* Cleanup after test.
79+
*/
7480
@After
7581
fun tearDown() {
7682
Dispatchers.resetMain()
7783
}
7884

79-
// ── Default state ────────────────────────────────────────────────
80-
85+
/**
86+
* Test default state of the ViewModel is non-windowed with null reader and zero offsets.
87+
*/
8188
@Test
8289
fun testDefaultStateNotWindowed() {
8390
val vm = TextEditorActivityViewModel()
@@ -89,6 +96,9 @@ class TextEditorActivityViewModelTest {
8996
assertNull(vm.windowContent.value)
9097
}
9198

99+
/**
100+
* Test non-windowed state properties are initialized to expected defaults (null/false/empty).
101+
*/
92102
@Test
93103
fun testDefaultNonWindowedState() {
94104
val vm = TextEditorActivityViewModel()
@@ -101,14 +111,18 @@ class TextEditorActivityViewModelTest {
101111
assertFalse(vm.markdownPreviewEnabled)
102112
}
103113

104-
// ── Markdown preview state ───────────────────────────────────────
105-
114+
/**
115+
* Test markdown preview is disabled by default and can be toggled on/off correctly.
116+
*/
106117
@Test
107118
fun testMarkdownPreviewDefaultDisabled() {
108119
val vm = TextEditorActivityViewModel()
109120
assertFalse(vm.markdownPreviewEnabled)
110121
}
111122

123+
/**
124+
* Test toggling markdown preview on and off updates the state as expected.
125+
*/
112126
@Test
113127
fun testMarkdownPreviewToggle() {
114128
val vm = TextEditorActivityViewModel()
@@ -118,8 +132,9 @@ class TextEditorActivityViewModelTest {
118132
assertFalse(vm.markdownPreviewEnabled)
119133
}
120134

121-
// ── Windowed state initialization ────────────────────────────────
122-
135+
/**
136+
* Test initializing windowed mode properties with a valid reader and file size sets the state correctly.
137+
*/
123138
@Test
124139
fun testInitializeWindowedMode() {
125140
val vm = TextEditorActivityViewModel()
@@ -141,8 +156,10 @@ class TextEditorActivityViewModelTest {
141156
reader.close()
142157
}
143158

144-
// ── loadWindow: forward ──────────────────────────────────────────
145-
159+
/**
160+
* Test loadWindow shifts the window forward and emits new content when a reader is set and
161+
* not at end of file.
162+
*/
146163
@Test
147164
fun testLoadWindowForward() =
148165
runTest(testDispatcher) {
@@ -169,8 +186,10 @@ class TextEditorActivityViewModelTest {
169186
reader.close()
170187
}
171188

172-
// ── loadWindow: backward ─────────────────────────────────────────
173-
189+
/**
190+
* Test loadWindow shifts the window backward and emits new content when a reader is set and
191+
* not at start of file.
192+
*/
174193
@Test
175194
fun testLoadWindowBackward() =
176195
runTest(testDispatcher) {
@@ -197,8 +216,10 @@ class TextEditorActivityViewModelTest {
197216
reader.close()
198217
}
199218

200-
// ── loadWindow: no-op at boundaries ──────────────────────────────
201-
219+
/**
220+
* Test loadWindow does not emit new content when trying to shift forward at end of file
221+
* (no-op).
222+
*/
202223
@Test
203224
fun testLoadWindowForwardNoOpAtEndOfFile() =
204225
runTest(testDispatcher) {
@@ -224,6 +245,10 @@ class TextEditorActivityViewModelTest {
224245
reader.close()
225246
}
226247

248+
/**
249+
* Test loadWindow does not emit new content when trying to shift backward at start of file
250+
* (no-op).
251+
*/
227252
@Test
228253
fun testLoadWindowBackwardNoOpAtStartOfFile() =
229254
runTest(testDispatcher) {
@@ -248,8 +273,9 @@ class TextEditorActivityViewModelTest {
248273
reader.close()
249274
}
250275

251-
// ── loadWindow: no-op when no reader ─────────────────────────────
252-
276+
/**
277+
* Test loadWindow does not emit new content when no reader is set (no-op).
278+
*/
253279
@Test
254280
fun testLoadWindowNoOpWithoutReader() =
255281
runTest(testDispatcher) {
@@ -266,8 +292,10 @@ class TextEditorActivityViewModelTest {
266292
assertNull(result)
267293
}
268294

269-
// ── Window byte offsets updated after load ───────────────────────
270-
295+
/**
296+
* Test that after loadWindow, the windowStartByte and windowEndByte are updated to reflect
297+
* the new window position based on the result from the reader.
298+
*/
271299
@Test
272300
fun testWindowByteOffsetsUpdatedAfterLoad() =
273301
runTest(testDispatcher) {
@@ -299,8 +327,9 @@ class TextEditorActivityViewModelTest {
299327
reader.close()
300328
}
301329

302-
// ── onCleared closes reader ──────────────────────────────────────
303-
330+
/**
331+
* Test that onCleared properly closes the fileWindowReader to release resources.
332+
*/
304333
@Test
305334
fun testOnClearedClosesReader() {
306335
val vm = TextEditorActivityViewModel()
@@ -319,14 +348,15 @@ class TextEditorActivityViewModelTest {
319348
var threwException = false
320349
try {
321350
reader.readWindow(0, 100)
322-
} catch (e: Exception) {
351+
} catch (_: Throwable) {
323352
threwException = true
324353
}
325354
assertTrue("Reader should be closed after onCleared", threwException)
326355
}
327356

328-
// ── Direction enum values ────────────────────────────────────────
329-
357+
/**
358+
* Test that the Direction enum has the expected values and order.
359+
*/
330360
@Test
331361
fun testDirectionEnum() {
332362
val values = TextEditorActivityViewModel.Direction.values()
@@ -335,8 +365,6 @@ class TextEditorActivityViewModelTest {
335365
assertEquals(TextEditorActivityViewModel.Direction.BACKWARD, values[1])
336366
}
337367

338-
// ── Helpers ──────────────────────────────────────────────────────
339-
340368
/** Creates a ViewModel configured for windowed mode with testDispatcher for IO. */
341369
private fun createWindowedViewModel(): TextEditorActivityViewModel {
342370
val vm = TextEditorActivityViewModel()

app/src/test/scripts/create-huge-complex-markdown.sh

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ set -euo pipefail
44
output="realistic.md"
55
> "$output"
66

7-
for i in {1..20}; do
7+
for i in {1..2}; do
88
cat <<PART >> "$output"
99
1010
# Document #$i$(date --utc +%Y-%m-%dT%H:%M:%SZ)
@@ -35,16 +35,18 @@ $(for ((j=1; j<=300; j++)); do
3535
[
3636
$(for ((k=1; k<=80; k++)); do
3737
# Smaller random payload to keep generation speed reasonable
38-
payload=$(head -c $((60 + (k % 140))) /dev/urandom 2>/dev/null | base64 -w 0 | head -c 120)
39-
cat <<JSON
38+
random_payload=$(head -c $((60 + (k % 140))) /dev/urandom 2>/dev/null | base64 -w 0 | head -c 120)
39+
json=$(cat <<JSON
4040
{
4141
"id": $((i*1000 + k)),
4242
"timestamp": "$(date --utc +%Y-%m-%dT%H:%M:%SZ)",
4343
"event": "click",
44-
"payload": "$payload",
44+
"payload": "$random_payload",
4545
"ip": "192.168.$((i % 255)).$((k % 255))"
4646
}$( [[ $k -lt 80 ]] && echo "," || echo "" )
4747
JSON
48+
)
49+
echo "$json"
4850
done
4951
)
5052
@@ -65,13 +67,13 @@ done)
6567
6668
## Image & link references
6769
68-
![Widget $(printf %04d $i)](https://picsum.photos/seed/doc$i/1200/800?grayscale)
70+
![Widget $(printf "%04d$i")](https://picsum.photos/seed/doc$i/1200/800?grayscale)
6971
→ [Open report #$i](https://demo.app/reports/$i?token=$(head -c 8 /dev/urandom | xxd -p -c 16))
7072
7173
PART
7274

7375
# Show progress
74-
(( i % 200 == 0 )) && du -h "$output" | awk '{print " → " $1 " so far (document " "'$i'")}'
76+
(( i % 200 == 0 )) && du -h "$output" | awk "{print \"\" $1 \" so far (document \" \"'$i'\")}"
7577

7678
done
7779

0 commit comments

Comments
 (0)