Preserve native title line boxes and async focus
This commit is contained in:
parent
a8951827ba
commit
43ae716157
9 changed files with 187 additions and 32 deletions
|
|
@ -0,0 +1,88 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.TitleItem
|
||||
import org.justinvisser.wolfskin.design.TitlePageData
|
||||
import org.justinvisser.wolfskin.design.TitleReadStatus
|
||||
import org.justinvisser.wolfskin.design.TitleSheet
|
||||
import org.justinvisser.wolfskin.design.TitleTrack
|
||||
import org.justinvisser.wolfskin.design.TitleVersion
|
||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
/** A delayed service reply exercises the production destination, view-model and Android focus transfer. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class TitleAsyncFocusTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var viewModel: WolfskinTitleViewModel
|
||||
|
||||
@After fun dispose() {
|
||||
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
||||
}
|
||||
|
||||
@Test fun loadingToReadyRestoresRealKeyFocusAndSheetReturn() {
|
||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000012")
|
||||
val ready = CompletableDeferred<TitlePageData>()
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.readSaved(id, any()) } returns null
|
||||
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
|
||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true))
|
||||
val person = ShellPerson("justin", "Justin", "J", false)
|
||||
val route = titleRoute(id.toString())
|
||||
val shell = ShellState(listOf(person), person.key).apply {
|
||||
pickerVisible = false
|
||||
pickerLaunch = false
|
||||
enterDestination(route)
|
||||
}
|
||||
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
|
||||
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
||||
rule.waitForIdle()
|
||||
assertTrue(shell.navFocused)
|
||||
press(Key.DirectionDown)
|
||||
assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused)
|
||||
|
||||
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
|
||||
rule.runOnIdle {
|
||||
ready.complete(TitlePageData(TitleItem(id.toString(), "A held film", "movie", held = true), versions = listOf(source, source.copy(id = "second", name = "Alternate"))))
|
||||
}
|
||||
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Ready && !shell.navFocused }
|
||||
press(Key.DirectionRight)
|
||||
val state = requireNotNull(viewModel.title)
|
||||
assertEquals("version", state.options[state.column].id)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.Version, state.sheet)
|
||||
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||
assertNull(state.sheet)
|
||||
press(Key.DirectionRight)
|
||||
assertEquals("tracks", state.options[state.column].id)
|
||||
assertFalse(shell.navFocused)
|
||||
}
|
||||
|
||||
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ import androidx.compose.ui.test.onNodeWithTag
|
|||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.json.JSONObject
|
||||
import org.junit.Before
|
||||
|
|
@ -115,6 +117,30 @@ class TitleCaptureTest {
|
|||
.put("boundsPx", org.json.JSONArray(listOf(heading.boundsInRoot.left, heading.boundsInRoot.top, heading.boundsInRoot.right, heading.boundsInRoot.bottom)))
|
||||
File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2))
|
||||
}
|
||||
recordTextMetrics(directory, number)
|
||||
}
|
||||
|
||||
private fun recordTextMetrics(directory: File, number: Int) {
|
||||
val elements = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true)
|
||||
val records = org.json.JSONArray()
|
||||
elements.fetchSemanticsNodes().forEachIndexed { index, node ->
|
||||
if (node.boundsInRoot.width > 0 && node.boundsInRoot.height > 0) {
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
elements[index].performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
|
||||
val layout = layouts.single()
|
||||
records.put(JSONObject().put("text", layout.layoutInput.text.text)
|
||||
.put("fontSizeSp", layout.layoutInput.style.fontSize.value)
|
||||
.put("weight", layout.layoutInput.style.fontWeight?.weight)
|
||||
.put("colourArgb", Integer.toUnsignedString(layout.layoutInput.style.color.toArgb(), 16))
|
||||
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL)
|
||||
.put("lineCount", layout.lineCount)
|
||||
.put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom)))
|
||||
.put("spans", org.json.JSONArray(layout.layoutInput.text.spanStyles.map { span ->
|
||||
JSONObject().put("start", span.start).put("end", span.end).put("weight", span.item.fontWeight?.weight)
|
||||
})))
|
||||
}
|
||||
}
|
||||
File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2))
|
||||
}
|
||||
|
||||
private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) }
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import org.justinvisser.wolfskin.design.TitleSurface
|
|||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
|
|
@ -289,4 +290,15 @@ class TitleKeysTest {
|
|||
val genre = rule.onNodeWithText("Drama", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
|
||||
}
|
||||
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
|
||||
render()
|
||||
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
|
||||
press(Key.DirectionCenter)
|
||||
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
|
||||
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,16 +200,23 @@ button until Conductarr's trailer field exists. Conductarr sign-in and request
|
|||
submission/receipt workflows belong to their separate slices; title pages do not
|
||||
pretend those operations succeeded or fall back to Seerr.
|
||||
|
||||
`TitleCaptureTest` writes the 34 approved title/person/options frames and native
|
||||
heading layout measurements at 1920×1080, density 2.0. The approved typeface ruling
|
||||
keeps platform Roboto rather than bundling the browser's DejaVu Sans: there is no
|
||||
per-title compensation and no wrapping-parity claim. `TitleKeysTest` replays
|
||||
real Android key events from title entry, including modal Back, held OK, nearest
|
||||
row entry, remembered columns, pending seasons and mouse-wheel re-entry.
|
||||
`title-press-paths.json` also retains the complete launch paths and their entry
|
||||
boundaries; their Home prefixes require the Home surface before end-to-end replay.
|
||||
SDK cache, exact-episode, track-index, identity, locale-layout and trailer-handler
|
||||
tests run entirely offline with in-process service replies and local art.
|
||||
`TitleCaptureTest` writes the 34 approved title/person/options frames plus native
|
||||
text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved
|
||||
typeface ruling keeps platform Roboto, not the browser's DejaVu Sans: no per-title
|
||||
compensation and no wrapping-parity claim. Untrimmed CSS line boxes are scoped to
|
||||
title/person content and sheets; shared nav/library typography is unchanged.
|
||||
`TitleKeysTest` replays real Android keys from title entry, including modal Back,
|
||||
held OK, nearest-row entry, remembered columns, pending seasons, wheel re-entry
|
||||
and native media-info line-box pitch. `TitleAsyncFocusTest` delays the service reply
|
||||
through the production destination/view-model, then exercises loaded options and
|
||||
hardware sheet Back through real focus handling.
|
||||
|
||||
The approved current gate uses exact title-entry suffixes, not padded launch
|
||||
paths. `title-press-paths.json` retains the full sequences and entry boundaries.
|
||||
The full 4/8/15/11 launch replays will be added after the native Home slice merges;
|
||||
the corresponding title-entry paths are 1/5/6/2. SDK cache, exact-episode,
|
||||
track-index, identity, locale-layout and trailer-handler tests run offline with
|
||||
in-process service replies and local art.
|
||||
|
||||
|
||||
## Toolchain and disk use
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier,
|
|||
Row(Modifier.align(Alignment.TopEnd)
|
||||
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
|
||||
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
|
||||
.padding(horizontal = V.side), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
.padding(horizontal = V.side + V.border), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("wifi", C.warn, Modifier.size(V.icon))
|
||||
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,15 +131,15 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
|||
Row(Modifier.offset(L.safeSide, D.personTop), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
PersonPortrait(state.person, Modifier.size(L.personPortrait).clip(CircleShape), true)
|
||||
Column(Modifier.width(L.contentWidth - L.personPortrait - D.personGap)) {
|
||||
Text(state.person.name, color = C.fg, style = WolfskinTokens.Type.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom))
|
||||
Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = WolfskinTokens.Type.caption, maxLines = 3)
|
||||
Text(state.person.name, color = C.fg, style = TitleTextStyles.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom))
|
||||
Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = TitleTextStyles.body)
|
||||
if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = TitleTextStyles.caption, maxLines = 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "filmography", contentType = "header") {
|
||||
Text("Filmography", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
|
||||
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
|
||||
}
|
||||
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
|
||||
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
|
|
|
|||
|
|
@ -70,24 +70,24 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
|||
|
||||
@Composable
|
||||
private fun VersionChoices(state: TitleState) {
|
||||
Text("Version", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle)
|
||||
SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files")
|
||||
state.data.versions.forEachIndexed { index, version ->
|
||||
PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
|
||||
}
|
||||
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = WolfskinTokens.Type.caption)
|
||||
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackChoices(state: TitleState) {
|
||||
val version = state.version ?: return
|
||||
Text("Audio and subtitles", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle)
|
||||
SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}")
|
||||
Text("Audio", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
version.audio.forEachIndexed { index, track ->
|
||||
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
|
||||
}
|
||||
Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
val start = version.audio.size
|
||||
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
|
||||
version.subtitles.forEachIndexed { index, track ->
|
||||
|
|
@ -107,8 +107,8 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
|
|||
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(label, color = foreground, style = WolfskinTokens.Type.listTitle)
|
||||
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = WolfskinTokens.Type.caption)
|
||||
Text(label, color = foreground, style = TitleTextStyles.listTitle)
|
||||
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption)
|
||||
}
|
||||
TitleRadio(selected, focused)
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
|
|||
@Composable
|
||||
private fun MediaInformation(state: TitleState) {
|
||||
val version = state.version ?: return
|
||||
Text("Media info", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle)
|
||||
SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name)
|
||||
MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known")))
|
||||
MediaInfoSection("Video", listOf("Stream" to version.video))
|
||||
|
|
@ -128,11 +128,11 @@ private fun MediaInformation(state: TitleState) {
|
|||
|
||||
@Composable
|
||||
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
|
||||
Text(label, color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
|
||||
Text(name, color = C.dim, style = D.infoKey, modifier = Modifier.width(D.infoLabelWidth))
|
||||
Text(value, color = C.fg, style = WolfskinTokens.Type.caption)
|
||||
Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth))
|
||||
Text(value, color = C.fg, style = TitleTextStyles.caption)
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ private fun MediaInfoSection(label: String, values: List<Pair<String, String>>)
|
|||
@Composable
|
||||
private fun WatchedConfirmation(state: TitleState) {
|
||||
val verb = if (state.watched) "unwatched" else "watched"
|
||||
Text("Mark the whole series as $verb?", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
||||
Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle)
|
||||
SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
|
|
@ -150,7 +150,7 @@ private fun WatchedConfirmation(state: TitleState) {
|
|||
|
||||
@Composable
|
||||
private fun SheetSubtitle(text: String) {
|
||||
Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.layout { measurable, constraints ->
|
||||
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.layout { measurable, constraints ->
|
||||
val child = measurable.measure(constraints)
|
||||
val lift = D.sheetSubLift.roundToPx()
|
||||
layout(child.width, (child.height + lift).coerceAtLeast(0)) { child.placeRelative(0, lift) }
|
||||
|
|
@ -163,9 +163,9 @@ private fun HiddenReceipt(state: TitleState) {
|
|||
verticalArrangement = Arrangement.spacedBy(D.receiptGap)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("check", C.fg, Modifier.size(D.receiptIcon))
|
||||
Text("Hidden from your picks", color = C.fg, style = D.receiptTitle)
|
||||
Text("Hidden from your picks", color = C.fg, style = TitleTextStyles.receiptTitle)
|
||||
}
|
||||
Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = TitleTextStyles.body)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
|
|
|
|||
|
|
@ -201,12 +201,13 @@ private fun TitleReadError(state: TitleState) {
|
|||
Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth)
|
||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(WolfskinTokens.SavedIndicator.border)
|
||||
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
|
||||
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("alert", C.warn, Modifier.size(D.receiptIcon))
|
||||
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle)
|
||||
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = TitleTextStyles.errorTitle)
|
||||
}
|
||||
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
|
||||
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
|
||||
/** Explicit styles retain CSS leading without changing shared nav or library typography. */
|
||||
internal object TitleTextStyles {
|
||||
private val lineBoxes = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
|
||||
private fun TextStyle.withLineBoxes() = copy(lineHeightStyle = lineBoxes)
|
||||
|
||||
val body = WolfskinTokens.Type.body.withLineBoxes()
|
||||
val caption = WolfskinTokens.Type.caption.withLineBoxes()
|
||||
val label = WolfskinTokens.Type.label.withLineBoxes()
|
||||
val sheetTitle = WolfskinTokens.Type.sheetTitle.withLineBoxes()
|
||||
val listTitle = WolfskinTokens.Type.listTitle.withLineBoxes()
|
||||
val screenTitle = WolfskinTokens.Type.screenTitle.withLineBoxes()
|
||||
val rowHeading = WolfskinTokens.Type.rowHeading.withLineBoxes()
|
||||
val errorTitle = WolfskinTokens.Title.errorTitle.withLineBoxes()
|
||||
val receiptTitle = WolfskinTokens.Title.receiptTitle.withLineBoxes()
|
||||
val infoKey = WolfskinTokens.Title.infoKey.withLineBoxes()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue