Skip to content

Commit 42be024

Browse files
authored
Merge pull request #223 from yukuku/claude/youthful-jepsen-e13da2
fix(reader): balance 2-line wrap of toolbar title so chapter number isn't clipped
2 parents 51c9a9a + 74a3c52 commit 42be024

4 files changed

Lines changed: 348 additions & 1 deletion

File tree

Alkitab/src/main/java/yuku/alkitab/base/IsiActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1561,7 +1561,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener, VerseAct
15611561

15621562
// set goto button text
15631563
val reference = activeSplit0.book.reference(available_chapter_1)
1564-
bGoto.text = reference.replace(' ', '\u00a0')
1564+
bGoto.text = reference
15651565

15661566
if (fullScreen) {
15671567
fullscreenReferenceToast?.cancel()

Alkitab/src/main/java/yuku/alkitab/base/widget/GotoButton.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package yuku.alkitab.base.widget;
22

33
import android.content.Context;
4+
import android.text.TextUtils;
45
import android.util.AttributeSet;
56
import android.view.MotionEvent;
67
import androidx.appcompat.widget.AppCompatButton;
@@ -15,22 +16,107 @@ public interface FloaterDragListener {
1516
void onFloaterDragComplete(float screenX, float screenY);
1617
}
1718

19+
public interface WidthMeasurer {
20+
float measure(CharSequence s, int start, int end);
21+
}
22+
1823
int[] screenLocation = {0, 0};
1924
boolean inFloaterDrag;
2025
boolean inLongClicked;
2126
int untouchableSideWidth = Integer.MIN_VALUE;
2227
FloaterDragListener floaterDragListener;
2328

29+
// rawText is the logical label; the superclass renders a possibly line-broken display copy of it
30+
CharSequence rawText = "";
31+
int lastWrapWidth = -1;
32+
CharSequence lastWrapSource = null;
33+
boolean applyingWrap;
34+
2435
public GotoButton(final Context context) {
2536
super(context);
37+
init();
2638
}
2739

2840
public GotoButton(final Context context, final AttributeSet attrs) {
2941
super(context, attrs);
42+
init();
3043
}
3144

3245
public GotoButton(final Context context, final AttributeSet attrs, final int defStyle) {
3346
super(context, attrs, defStyle);
47+
init();
48+
}
49+
50+
private void init() {
51+
setMaxLines(2);
52+
setEllipsize(TextUtils.TruncateAt.END);
53+
}
54+
55+
@Override
56+
public void setText(final CharSequence text, final BufferType type) {
57+
if (!applyingWrap) {
58+
rawText = text == null ? "" : text;
59+
lastWrapSource = null;
60+
}
61+
super.setText(text, type);
62+
}
63+
64+
@Override
65+
protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
66+
final int availWidth = MeasureSpec.getSize(widthMeasureSpec) - getPaddingLeft() - getPaddingRight();
67+
if (availWidth > 0 && (availWidth != lastWrapWidth || rawText != lastWrapSource)) {
68+
lastWrapWidth = availWidth;
69+
lastWrapSource = rawText;
70+
71+
final String display = balanceWrap(rawText, availWidth, getPaint()::measureText);
72+
applyingWrap = true;
73+
super.setText(display, BufferType.NORMAL);
74+
applyingWrap = false;
75+
}
76+
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
77+
}
78+
79+
public static String balanceWrap(final CharSequence text, final float availWidth, final WidthMeasurer measurer) {
80+
final int n = text.length();
81+
if (n == 0 || measurer.measure(text, 0, n) <= availWidth) {
82+
return text.toString();
83+
}
84+
85+
int bestSplit = -1;
86+
float bestMax = Float.MAX_VALUE;
87+
boolean foundFit = false;
88+
89+
for (int i = 1; i < n; i++) {
90+
int end1 = i;
91+
while (end1 > 0 && text.charAt(end1 - 1) == ' ') end1--;
92+
int start2 = i;
93+
while (start2 < n && text.charAt(start2) == ' ') start2++;
94+
if (end1 == 0 || start2 == n) continue;
95+
96+
final float w1 = measurer.measure(text, 0, end1);
97+
final float w2 = measurer.measure(text, start2, n);
98+
final float mx = Math.max(w1, w2);
99+
final boolean fits = w1 <= availWidth && w2 <= availWidth;
100+
101+
if (fits && !foundFit) {
102+
foundFit = true;
103+
bestMax = mx;
104+
bestSplit = i;
105+
} else if (fits == foundFit && mx < bestMax) {
106+
bestMax = mx;
107+
bestSplit = i;
108+
}
109+
}
110+
111+
if (bestSplit < 0) {
112+
return text.toString();
113+
}
114+
115+
int end1 = bestSplit;
116+
while (end1 > 0 && text.charAt(end1 - 1) == ' ') end1--;
117+
int start2 = bestSplit;
118+
while (start2 < n && text.charAt(start2) == ' ') start2++;
119+
return text.subSequence(0, end1).toString() + "\n" + text.subSequence(start2, n).toString();
34120
}
35121

36122
@Override
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package yuku.alkitab.base.widget
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
class GotoButtonBalanceWrapTest {
8+
private val byLength = GotoButton.WidthMeasurer { _, start, end -> (end - start).toFloat() }
9+
10+
@Test
11+
fun `text that fits the available width is returned unchanged on a single line`() {
12+
assertEquals("Kej 1", GotoButton.balanceWrap("Kej 1", 10f, byLength))
13+
}
14+
15+
@Test
16+
fun `a three token reference that does not fit is split into two balanced lines keeping the chapter number`() {
17+
val out = GotoButton.balanceWrap("1 Tesalonika 2", 8f, byLength)
18+
assertEquals("1 Tesal\nonika 2", out)
19+
val lines = out.split("\n")
20+
assertEquals(2, lines.size)
21+
assertTrue(lines.all { it.length <= 8 })
22+
assertTrue(out.replace("\n", " ").endsWith("2"))
23+
}
24+
25+
@Test
26+
fun `the split breaks mid word rather than only at whitespace when that balances better`() {
27+
val out = GotoButton.balanceWrap("Wahyu 22", 5f, byLength)
28+
val lines = out.split("\n")
29+
assertEquals(2, lines.size)
30+
assertTrue(lines.all { it.length <= 5 })
31+
}
32+
33+
@Test
34+
fun `a leading space on the second line is trimmed when the split lands on whitespace`() {
35+
val out = GotoButton.balanceWrap("abc 123", 4f, byLength)
36+
assertEquals("abc\n123", out)
37+
}
38+
39+
@Test
40+
fun `empty text is returned unchanged`() {
41+
assertEquals("", GotoButton.balanceWrap("", 5f, byLength))
42+
}
43+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package yuku.alkitab.base.widget
2+
3+
import android.graphics.Bitmap
4+
import android.graphics.Canvas
5+
import android.graphics.Color as AndroidColor
6+
import android.graphics.Typeface
7+
import android.util.TypedValue
8+
import android.view.Gravity
9+
import android.view.View
10+
import androidx.appcompat.app.AppCompatActivity
11+
import androidx.appcompat.widget.AppCompatButton
12+
import androidx.test.core.app.ApplicationProvider
13+
import java.io.File
14+
import java.io.FileOutputStream
15+
import org.junit.Assert.assertTrue
16+
import org.junit.Before
17+
import org.junit.Test
18+
import org.junit.runner.RunWith
19+
import org.robolectric.Robolectric
20+
import org.robolectric.RobolectricTestRunner
21+
import org.robolectric.annotation.Config
22+
import org.robolectric.annotation.GraphicsMode
23+
import yuku.afw.App as AfwApp
24+
import yuku.alkitab.debug.R
25+
26+
/**
27+
* Visual side-by-side snapshot test for the reader toolbar title button.
28+
*
29+
* The left column is the OLD behaviour: the title was set as
30+
* `reference.replace(' ', ' ')` on a plain 2-line button, so the whole
31+
* reference was one unbreakable token — when it didn't fit, the default
32+
* TextView line-breaking could only ellipsize, clipping the chapter number.
33+
*
34+
* The right column is the NEW [GotoButton], which wraps an overflowing title
35+
* into two character-balanced lines (mid-word allowed) so both lines fit and
36+
* the chapter number stays visible.
37+
*
38+
* Each (reference, width) case is rendered through both paths, the bitmaps are
39+
* saved as PNGs under `Alkitab/build/snapshots/goto-button/`, and an
40+
* `index.html` is generated with a two-column comparison table for eyeballing.
41+
*
42+
* Not a strict pixel-diff: PNGs are written, not compared. Robolectric's text
43+
* metrics are uniform and narrower than a real device, so on-device overflow
44+
* widths don't match here and the dramatic chapter-number clip is hard to
45+
* reproduce headless; the report is for eyeballing relative behaviour, not
46+
* pixel-accurate device fidelity.
47+
*/
48+
@RunWith(RobolectricTestRunner::class)
49+
@Config(application = AfwApp::class, sdk = [34], qualifiers = "w360dp-h640dp-mdpi")
50+
@GraphicsMode(GraphicsMode.Mode.NATIVE)
51+
class GotoButtonSideBySideSnapshotTest {
52+
53+
private val TEXT_SIZE_SP = 16f
54+
private val BUTTON_HEIGHT_PX = 56
55+
private val TEXT_COLOR = AndroidColor.WHITE
56+
private val BUTTON_BG = 0xff3367d6.toInt()
57+
58+
@Before
59+
fun setUp() {
60+
AfwApp.initWithAppContext(ApplicationProvider.getApplicationContext())
61+
}
62+
63+
@Test
64+
fun `produce side-by-side snapshots of the toolbar title for many references and widths`() {
65+
val cases = buildCases()
66+
val outputDir = resolveSnapshotDir()
67+
outputDir.mkdirs()
68+
69+
val rows = StringBuilder()
70+
71+
for (case in cases) {
72+
val oldBitmap = renderOld(case)
73+
val newBitmap = renderNew(case)
74+
75+
val oldFile = File(outputDir, "${case.name}-old.png")
76+
val newFile = File(outputDir, "${case.name}-new.png")
77+
FileOutputStream(oldFile).use { oldBitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
78+
FileOutputStream(newFile).use { newBitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
79+
80+
rows.append(
81+
"<tr>" +
82+
"<td class=\"label\"><div class=\"name\">${escapeHtml(case.name)}</div><pre>${escapeHtml(case.describe())}</pre></td>" +
83+
"<td><img src=\"${oldFile.name}\"/></td>" +
84+
"<td><img src=\"${newFile.name}\"/></td>" +
85+
"</tr>\n"
86+
)
87+
}
88+
89+
val html = """
90+
<!doctype html>
91+
<html><head><meta charset="utf-8"/><title>GotoButton title wrap: old vs new</title>
92+
<style>
93+
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 24px; }
94+
table { border-collapse: collapse; }
95+
td, th { vertical-align: top; padding: 8px 12px; border-bottom: 1px solid #ddd; }
96+
th { text-align: left; background: #f4f4f4; position: sticky; top: 0; }
97+
td.label { width: 320px; }
98+
td.label .name { font-weight: 600; }
99+
td.label pre { margin: 4px 0 0; white-space: pre-wrap; word-break: break-word; color: #444; font-size: 12px; }
100+
img { display: block; image-rendering: pixelated; border: 1px solid #eee; }
101+
</style></head>
102+
<body>
103+
<h1>Reader toolbar title: old vs new wrapping</h1>
104+
<p>${cases.size} cases. Left = old (non-breaking-space token, ellipsized when overflowing — chapter number clipped). Right = new GotoButton balanced 2-line wrap (chapter number preserved). Blue box edges show the available button width.</p>
105+
<table>
106+
<thead><tr><th>Case</th><th>Old (nbsp, default wrap)</th><th>New (balanced wrap)</th></tr></thead>
107+
<tbody>
108+
${rows}
109+
</tbody>
110+
</table>
111+
</body></html>
112+
""".trimIndent()
113+
114+
val indexFile = File(outputDir, "index.html")
115+
indexFile.writeText(html)
116+
117+
println("Snapshot report written to: ${indexFile.absolutePath}")
118+
assertTrue("index.html should exist", indexFile.exists())
119+
}
120+
121+
private fun resolveSnapshotDir(): File {
122+
val override = System.getenv("GOTO_BUTTON_SNAPSHOT_DIR")
123+
if (!override.isNullOrBlank()) return File(override)
124+
val moduleDir = File(System.getProperty("user.dir") ?: ".")
125+
return File(moduleDir, "build/snapshots/goto-button")
126+
}
127+
128+
// ---- Test data ------------------------------------------------------------------------------
129+
130+
private data class Case(
131+
val name: String,
132+
val text: String,
133+
val widthPx: Int,
134+
) {
135+
fun describe(): String = "text=\"$text\"\nwidth=${widthPx}px"
136+
}
137+
138+
private fun buildCases(): List<Case> {
139+
val references = listOf(
140+
"Kej 1",
141+
"Wahyu 22",
142+
"1 Korintus 16",
143+
"1 Tesalonika 2",
144+
"Kidung Agung 8",
145+
"Pengkhotbah 12",
146+
)
147+
val widths = listOf(40, 60, 80, 100, 120, 160)
148+
val cases = mutableListOf<Case>()
149+
var i = 1
150+
for (ref in references) {
151+
for (w in widths) {
152+
val slug = ref.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-')
153+
cases += Case("%02d-%s-w%d".format(i, slug, w), ref, w)
154+
i++
155+
}
156+
}
157+
return cases
158+
}
159+
160+
// ---- Rendering helpers ----------------------------------------------------------------------
161+
162+
private fun renderOld(case: Case): Bitmap {
163+
val button = AppCompatButton(buildActivity()).apply {
164+
applyCommonStyle()
165+
text = case.text.replace(' ', ' ')
166+
}
167+
return measureAndDraw(button, case.widthPx)
168+
}
169+
170+
private fun renderNew(case: Case): Bitmap {
171+
val button = GotoButton(buildActivity()).apply {
172+
applyCommonStyle()
173+
text = case.text
174+
}
175+
return measureAndDraw(button, case.widthPx)
176+
}
177+
178+
private fun AppCompatButton.applyCommonStyle() {
179+
maxLines = 2
180+
ellipsize = android.text.TextUtils.TruncateAt.END
181+
gravity = Gravity.CENTER
182+
isAllCaps = false
183+
includeFontPadding = false
184+
setPadding(0, 0, 0, 0)
185+
setTextColor(TEXT_COLOR)
186+
setBackgroundColor(BUTTON_BG)
187+
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
188+
setTextSize(TypedValue.COMPLEX_UNIT_SP, TEXT_SIZE_SP)
189+
}
190+
191+
private fun buildActivity(): AppCompatActivity {
192+
val activity = Robolectric.buildActivity(AppCompatActivity::class.java).setup().get()
193+
activity.setTheme(androidx.appcompat.R.style.Theme_AppCompat)
194+
return activity
195+
}
196+
197+
private fun measureAndDraw(view: View, widthPx: Int): Bitmap {
198+
// Measure/layout the detached button at exactly widthPx and draw straight
199+
// away. GotoButton wraps inside onMeasure via setText(), which schedules a
200+
// requestLayout; idling the looper here would flush that traversal and
201+
// re-measure the button at a different width, discarding the wrap.
202+
val widthSpec = View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY)
203+
val heightSpec = View.MeasureSpec.makeMeasureSpec(BUTTON_HEIGHT_PX, View.MeasureSpec.EXACTLY)
204+
view.measure(widthSpec, heightSpec)
205+
view.layout(0, 0, widthPx, BUTTON_HEIGHT_PX)
206+
207+
// Pin the bitmap to the requested width/height so every old/new pair is
208+
// the same size and the button's edges (and any overflow clip) are visible.
209+
val bitmap = Bitmap.createBitmap(widthPx, BUTTON_HEIGHT_PX, Bitmap.Config.ARGB_8888)
210+
val canvas = Canvas(bitmap)
211+
canvas.drawColor(AndroidColor.WHITE)
212+
view.draw(canvas)
213+
return bitmap
214+
}
215+
216+
private fun escapeHtml(s: String): String =
217+
s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
218+
}

0 commit comments

Comments
 (0)