Skip to content

Commit 647b526

Browse files
authored
R813: keep reader content in the tabletop upper region (#920)
Co-authored-by: ryacub <ryacub@users.noreply.github.com>
1 parent dccccc2 commit 647b526

3 files changed

Lines changed: 139 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
1313
## Unreleased
1414

1515
### Added
16+
- The reader keeps content in the upper region during tabletop posture. The reader restores the normal layout when the device leaves tabletop posture.
1617
- Library search comparison parsing now uses only documented aliases. The help explains that malformed comparison values never match and that negating one matches every entry.
1718
- Library search now supports the comparison fields `id`, `added`, `fetchinterval` (or `fi`), `nextupdate` (or `nu`), `unread`, `read`, and `total` with the operators `>=`, `<=`, `>`, `<`, and `=`; dates use the `yyyy-MM-dd` format. The `language:` (or `lang:`) field filters by source language, and the `notes:` (or `note:`) field is not supported. For anime, `unread` means unseen, `read` means seen, and `total` means total episodes.
1819
- Library search now supports the operators `&&` (AND), `||` (OR), and `-` (NOT), grouped expressions with parentheses, and double-quoted values. Field filters search by `title`, `author`, `artist`, `description` (or `desc`), `genre` (or `tag`), and `source` (or `src`); the `source:local` value matches local entries. Queries without these markers keep the old behavior, including comma lists, a leading minus, and the `id:` prefix. The new Search help item in the library menu explains the syntax.

app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import androidx.core.content.getSystemService
3434
import androidx.core.graphics.ColorUtils
3535
import androidx.core.net.toUri
3636
import androidx.core.transition.doOnEnd
37+
import androidx.core.view.ViewCompat
3738
import androidx.core.view.WindowCompat
3839
import androidx.core.view.WindowInsetsCompat
3940
import androidx.core.view.WindowInsetsControllerCompat
@@ -55,6 +56,10 @@ import eu.kanade.presentation.reader.ReaderPageActionsDialog
5556
import eu.kanade.presentation.reader.ReadingModeSelectDialog
5657
import eu.kanade.presentation.reader.appbars.ReaderAppBars
5758
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
59+
import eu.kanade.presentation.util.FoldOcclusionType
60+
import eu.kanade.presentation.util.FoldOrientation
61+
import eu.kanade.presentation.util.FoldState
62+
import eu.kanade.presentation.util.ReaderFoldState
5863
import eu.kanade.presentation.util.readerFoldStateFrom
5964
import eu.kanade.tachiyomi.R
6065
import eu.kanade.tachiyomi.core.common.Constants
@@ -282,6 +287,18 @@ class ReaderActivity : BaseActivity() {
282287
.collect(viewModel::setFoldState)
283288
}
284289
}
290+
291+
// Single source of truth for the tabletop upper-region constraint.
292+
// updateViewer() replaces the viewer on the main thread, and the next
293+
// fold-state emission applies the constraint to the current viewer.
294+
viewModel.state
295+
.map { it.foldState }
296+
.distinctUntilChanged()
297+
.onEach(::updateViewerForTabletopPosture)
298+
.launchIn(lifecycleScope)
299+
300+
// Close the startup gap before the observer's first emission.
301+
updateViewerForTabletopPosture(viewModel.state.value.foldState)
285302
}
286303

287304
/**
@@ -887,6 +904,27 @@ class ReaderActivity : BaseActivity() {
887904
}
888905
}
889906

907+
/**
908+
* Constrains the viewer height for the tabletop upper region.
909+
*
910+
* It runs on the fold state that the [ReaderViewModel.State.foldState]
911+
* observer emits. It reads the status bar inset from the viewer view so
912+
* the height is measured in content coordinates.
913+
*/
914+
private fun updateViewerForTabletopPosture(foldState: ReaderFoldState?) {
915+
val view = viewModel.state.value.viewer?.getView() ?: return
916+
val statusBarInset = ViewCompat.getRootWindowInsets(view)
917+
?.getInsets(WindowInsetsCompat.Type.statusBars())?.top ?: 0
918+
val tabletopHeight = tabletopViewerHeight(foldState, statusBarInset)
919+
val params = view.layoutParams as? FrameLayout.LayoutParams
920+
?: FrameLayout.LayoutParams(
921+
FrameLayout.LayoutParams.MATCH_PARENT,
922+
FrameLayout.LayoutParams.MATCH_PARENT,
923+
)
924+
params.height = tabletopHeight ?: FrameLayout.LayoutParams.MATCH_PARENT
925+
view.layoutParams = params
926+
}
927+
890928
/**
891929
* Class that observes ReaderConfigManager and applies config to Android Window/Views.
892930
*/
@@ -972,3 +1010,32 @@ class ReaderActivity : BaseActivity() {
9721010
}
9731011
}
9741012
}
1013+
1014+
/**
1015+
* Returns true when the device is in tabletop posture.
1016+
*
1017+
* Tabletop posture requires a horizontal, half-open fold that occludes the
1018+
* middle of the window fully.
1019+
*/
1020+
internal fun isInTabletopPosture(foldState: ReaderFoldState?): Boolean {
1021+
return foldState != null &&
1022+
foldState.orientation == FoldOrientation.Horizontal &&
1023+
foldState.state == FoldState.HalfOpen &&
1024+
foldState.occlusionType == FoldOcclusionType.Full
1025+
}
1026+
1027+
/**
1028+
* Computes the viewer height in pixels for the tabletop upper region.
1029+
*
1030+
* Returns null when the device is not in tabletop posture, so the caller uses
1031+
* MATCH_PARENT. In tabletop posture it returns the height above the fold,
1032+
* reduced by [statusBarInset] because the fold bounds live in window pixels.
1033+
* The result never goes below zero.
1034+
*/
1035+
internal fun tabletopViewerHeight(
1036+
foldState: ReaderFoldState?,
1037+
statusBarInset: Int,
1038+
): Int? {
1039+
if (foldState == null || !isInTabletopPosture(foldState)) return null
1040+
return (foldState.bounds.top - statusBarInset).coerceAtLeast(0)
1041+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package eu.kanade.tachiyomi.ui.reader
2+
3+
import eu.kanade.presentation.util.FoldBounds
4+
import eu.kanade.presentation.util.FoldOcclusionType
5+
import eu.kanade.presentation.util.FoldOrientation
6+
import eu.kanade.presentation.util.FoldState
7+
import eu.kanade.presentation.util.ReaderFoldState
8+
import io.kotest.matchers.nulls.shouldBeNull
9+
import io.kotest.matchers.shouldBe
10+
import org.junit.jupiter.api.Test
11+
12+
class ReaderViewerConstraintTest {
13+
14+
@Test
15+
fun `is in tabletop posture when horizontal half-open fully occluding`() {
16+
isInTabletopPosture(tabletopFold()) shouldBe true
17+
}
18+
19+
@Test
20+
fun `is not in tabletop posture when the fold is vertical`() {
21+
val fold = tabletopFold().copy(orientation = FoldOrientation.Vertical)
22+
23+
isInTabletopPosture(fold) shouldBe false
24+
}
25+
26+
@Test
27+
fun `is not in tabletop posture when the device is flat`() {
28+
val fold = tabletopFold().copy(state = FoldState.Flat)
29+
30+
isInTabletopPosture(fold) shouldBe false
31+
}
32+
33+
@Test
34+
fun `is not in tabletop posture when the fold does not occlude`() {
35+
val fold = tabletopFold().copy(occlusionType = FoldOcclusionType.None)
36+
37+
isInTabletopPosture(fold) shouldBe false
38+
}
39+
40+
@Test
41+
fun `is not in tabletop posture when there is no fold`() {
42+
isInTabletopPosture(null) shouldBe false
43+
}
44+
45+
@Test
46+
fun `heights to the upper region above the fold minus the status bar`() {
47+
val fold = tabletopFold(top = 720)
48+
49+
tabletopViewerHeight(fold, statusBarInset = 120) shouldBe 600
50+
}
51+
52+
@Test
53+
fun `returns null when not in tabletop posture`() {
54+
tabletopViewerHeight(tabletopFold().copy(state = FoldState.Flat), statusBarInset = 120)
55+
.shouldBeNull()
56+
}
57+
58+
@Test
59+
fun `coerces a negative height to zero when the status bar is larger than the fold`() {
60+
val fold = tabletopFold(top = 50)
61+
62+
tabletopViewerHeight(fold, statusBarInset = 120) shouldBe 0
63+
}
64+
65+
private fun tabletopFold(top: Int = 720): ReaderFoldState = ReaderFoldState(
66+
orientation = FoldOrientation.Horizontal,
67+
state = FoldState.HalfOpen,
68+
occlusionType = FoldOcclusionType.Full,
69+
bounds = FoldBounds(left = 0, top = top, right = 1440, bottom = top + 10),
70+
)
71+
}

0 commit comments

Comments
 (0)