Recently Written · git

ink-dim

Ink Dim: tap to set the frontlight of a Viwoods AiPaper reader below the lowest system level. Tap again to go back.

git clone https://github.com/equwal/ink-dim

Log | Files | Refs


app/src/main/java/dev/equwal/inkdim/Ui.kt (12089 bytes)

1 // SPDX-License-Identifier: GPL-3.0-or-later
2 // Copyright (c) 2026 equwal
3 package dev.equwal.inkdim
4 
5 import android.app.Activity
6 import android.content.Context
7 import android.graphics.Canvas
8 import android.graphics.Color
9 import android.graphics.Paint
10 import android.graphics.Path
11 import android.graphics.Typeface
12 import android.graphics.drawable.GradientDrawable
13 import android.text.TextUtils
14 import android.util.TypedValue
15 import android.view.Gravity
16 import android.view.View
17 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
18 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
19 import android.view.WindowInsets
20 import android.widget.Button
21 import android.widget.LinearLayout
22 import android.widget.ScrollView
23 import android.widget.TextView
24 
25 /**
26  * View construction, in code, with no support library.
27  *
28  * The screen is e-ink: no colour, slow refresh, and a hard time with light
29  * greys. So everything here is black on white at generous sizes, an outline is
30  * a real black line, and nothing animates.
31  *
32  * The rules this file keeps, so that each screen looks the same:
33  *
34  *  - 16 dp side margins, and spacing on an 8 dp grid.
35  *  - A row is at least 56 dp high, and the whole row is the touch target.
36  *  - Titles are 17 sp, notes 14 sp, section headers 13 sp small caps.
37  *  - Secondary text is #444444. Nothing lighter carries a fact.
38  *  - A button is 48 dp high and outlined. The main button is 56 dp and filled.
39  */
40 object Ui {
41 
42     const val INK = Color.BLACK
43 
44     /** Secondary text. The lightest grey that still reads on e-ink. */
45     val DIM = Color.rgb(68, 68, 68)
46 
47     /** Separator lines between rows. */
48     val RULE = Color.rgb(150, 150, 150)
49 
50     fun Context.dp(v: Int): Int = TypedValue.applyDimension(
51         TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics
52     ).toInt()
53 
54     private fun Context.px(v: Int): Int = maxOf(1, dp(v))
55 
56     // ---- page --------------------------------------------------------------
57 
58     /**
59      * Root of a screen. It gives back the column that the caller fills.
60      *
61      * With a [title] the screen gets the standard top bar: a back arrow with a
62      * 48 dp touch target, the title, and a rule under it. The content below
63      * scrolls only when it does not fit.
64      */
65     fun page(a: Activity, title: String? = null): LinearLayout {
66         val root = LinearLayout(a).apply {
67             orientation = LinearLayout.VERTICAL
68             setBackgroundColor(Color.WHITE)
69         }
70         if (title != null) {
71             root.addView(topBar(a, title))
72             root.addView(View(a).apply {
73                 setBackgroundColor(RULE)
74                 layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1)
75             })
76         }
77 
78         val col = LinearLayout(a).apply {
79             orientation = LinearLayout.VERTICAL
80             setPadding(a.dp(16), a.dp(if (title == null) 8 else 12), a.dp(16), a.dp(24))
81         }
82         val scroll = ScrollView(a).apply {
83             isFillViewport = true
84             clipToPadding = false
85             addView(col, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
86         }
87         root.addView(scroll, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f))
88 
89         // Edge to edge is enforced from Android 15, so the system bars overlap
90         // the window. The top bar moves down by what the status bar covers, and
91         // the content keeps clear of the navigation bar.
92         root.setOnApplyWindowInsetsListener { v, insets ->
93             val bars = insets.getInsets(
94                 WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout()
95             )
96             v.setPadding(bars.left, bars.top, bars.right, 0)
97             col.setPadding(
98                 col.paddingLeft, col.paddingTop, col.paddingRight, a.dp(24) + bars.bottom
99             )
100             insets
101         }
102         a.setContentView(root)
103         root.requestApplyInsets()
104         return col
105     }
106 
107     private fun topBar(a: Activity, text: String): View {
108         val bar = LinearLayout(a).apply {
109             orientation = LinearLayout.HORIZONTAL
110             gravity = Gravity.CENTER_VERTICAL
111             setPadding(a.dp(4), 0, a.dp(16), 0)
112             layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, a.dp(56))
113         }
114         bar.addView(Glyph(a, Glyph.BACK).apply {
115             contentDescription = "Back"
116             isClickable = true
117             isFocusable = true
118             @Suppress("DEPRECATION")
119             setOnClickListener { a.onBackPressed() }
120             layoutParams = LinearLayout.LayoutParams(a.dp(48), a.dp(48))
121         })
122         bar.addView(TextView(a).apply {
123             this.text = text
124             setTextColor(INK)
125             setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
126             setTypeface(Typeface.DEFAULT_BOLD)
127             maxLines = 1
128             ellipsize = TextUtils.TruncateAt.END
129             layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f).apply {
130                 marginStart = a.dp(4)
131             }
132         })
133         return bar
134     }
135 
136     // ---- text --------------------------------------------------------------
137 
138     fun LinearLayout.header(text: String) = add(TextView(context).apply {
139         this.text = text.uppercase()
140         setTextColor(DIM)
141         setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
142         setTypeface(Typeface.DEFAULT_BOLD)
143         letterSpacing = 0.08f
144         setPadding(0, context.dp(24), 0, context.dp(8))
145     })
146 
147     fun LinearLayout.note(text: String) = add(TextView(context).apply {
148         this.text = text
149         setTextColor(DIM)
150         setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
151         setLineSpacing(context.dp(3).toFloat(), 1f)
152         setPadding(0, 0, 0, context.dp(8))
153     })
154 
155     fun LinearLayout.rule() = add(View(context).apply {
156         setBackgroundColor(RULE)
157         layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1)
158     })
159 
160     // ---- rows --------------------------------------------------------------
161 
162     /**
163      * A row: what it is, what it does now, and either a state chip or a chevron
164      * on the right. The whole row is the touch target.
165      */
166     fun LinearLayout.row(
167         title: String,
168         subtitle: String? = null,
169         enabled: Boolean = true,
170         state: String? = null,
171         onClick: (() -> Unit)? = null
172     ): LinearLayout {
173         val box = LinearLayout(context).apply {
174             orientation = LinearLayout.HORIZONTAL
175             gravity = Gravity.CENTER_VERTICAL
176             minimumHeight = context.dp(56)
177             val v = context.dp(10)
178             setPadding(0, v, 0, v)
179             isClickable = onClick != null && enabled
180             if (isClickable) setOnClickListener { onClick?.invoke() }
181         }
182         val texts = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
183         texts.addView(TextView(context).apply {
184             text = title
185             setTextColor(if (enabled) INK else DIM)
186             setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f)
187         })
188         if (!subtitle.isNullOrBlank()) {
189             texts.addView(TextView(context).apply {
190                 text = subtitle
191                 setTextColor(DIM)
192                 setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
193                 setPadding(0, context.dp(2), 0, 0)
194             })
195         }
196         box.addView(texts, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
197 
198         if (state != null) {
199             box.addView(chip(context, state).apply {
200                 layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
201                     marginStart = context.dp(8)
202                 }
203             })
204         } else if (box.isClickable) {
205             box.addView(Glyph(context, Glyph.CHEVRON).apply {
206                 layoutParams = LinearLayout.LayoutParams(context.dp(24), context.dp(24)).apply {
207                     marginStart = context.dp(8)
208                 }
209             })
210         }
211         add(box)
212         rule()
213         return box
214     }
215 
216     // ---- buttons -----------------------------------------------------------
217 
218     /** The main action of a screen: filled, 56 dp, full width. */
219     fun LinearLayout.primaryButton(text: String, onClick: () -> Unit) =
220         add(flatButton(context, text, 56, Color.WHITE, INK, 17f).apply {
221             setOnClickListener { onClick() }
222         })
223 
224     /** Every other action: outlined, 48 dp, full width. */
225     fun LinearLayout.button(text: String, onClick: () -> Unit) =
226         add(flatButton(context, text, 48, INK, Color.WHITE, 16f).apply {
227             setOnClickListener { onClick() }
228         })
229 
230     /**
231      * A button with the platform look taken off: our own outline or fill, no
232      * elevation, and no ripple, which smears on an e-ink panel.
233      */
234     private fun flatButton(
235         c: Context,
236         text: String,
237         height: Int,
238         textColor: Int,
239         fill: Int,
240         size: Float
241     ): Button = Button(c).apply {
242         this.text = text
243         isAllCaps = false
244         gravity = Gravity.CENTER
245         setTextColor(textColor)
246         setTextSize(TypedValue.COMPLEX_UNIT_SP, size)
247         setTypeface(Typeface.DEFAULT_BOLD)
248         background = outline(c, fill)
249         stateListAnimator = null
250         elevation = 0f
251         minWidth = 0
252         minimumWidth = 0
253         minHeight = 0
254         minimumHeight = 0
255         setPadding(c.dp(16), 0, c.dp(16), 0)
256         layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, c.dp(height)).apply {
257             topMargin = c.dp(8)
258             bottomMargin = c.dp(8)
259         }
260     }
261 
262     // ---- drawing -----------------------------------------------------------
263 
264     /** A black outline on a fill: the one shape this app is built from. */
265     fun outline(c: Context, fill: Int = Color.WHITE, radius: Int = 2): GradientDrawable =
266         GradientDrawable().apply {
267             shape = GradientDrawable.RECTANGLE
268             setColor(fill)
269             cornerRadius = c.dp(radius).toFloat()
270             setStroke(c.px(1), INK)
271         }
272 
273     /** A short state word, in an outlined box. */
274     fun chip(c: Context, text: String): TextView = TextView(c).apply {
275         this.text = text
276         setTextColor(INK)
277         setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
278         setTypeface(Typeface.DEFAULT_BOLD)
279         background = outline(c, radius = 10)
280         setPadding(c.dp(8), c.dp(3), c.dp(8), c.dp(3))
281         maxLines = 1
282     }
283 
284     /**
285      * The back arrow and the row chevron. They are drawn, not typed, because a
286      * firmware font can be without the characters.
287      */
288     private class Glyph(c: Context, private val kind: Int) : View(c) {
289 
290         private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
291             color = INK
292             style = Paint.Style.STROKE
293             strokeWidth = c.resources.displayMetrics.density * 2f
294             strokeCap = Paint.Cap.ROUND
295             strokeJoin = Paint.Join.ROUND
296         }
297         private val path = Path()
298 
299         override fun onDraw(canvas: Canvas) {
300             val cx = width / 2f
301             val cy = height / 2f
302             path.reset()
303             if (kind == BACK) {
304                 val r = minOf(width, height) * 0.24f
305                 val h = r * 0.62f
306                 path.moveTo(cx + r, cy)
307                 path.lineTo(cx - r, cy)
308                 path.moveTo(cx - r + h, cy - h)
309                 path.lineTo(cx - r, cy)
310                 path.lineTo(cx - r + h, cy + h)
311             } else {
312                 val w = minOf(width, height) * 0.17f
313                 val h = w * 1.7f
314                 path.moveTo(cx - w, cy - h)
315                 path.lineTo(cx + w, cy)
316                 path.lineTo(cx - w, cy + h)
317             }
318             canvas.drawPath(path, paint)
319         }
320 
321         companion object {
322             const val BACK = 0
323             const val CHEVRON = 1
324         }
325     }
326 
327     /** Full width by default. A caller that wants otherwise sets the params first. */
328     private fun LinearLayout.add(v: View) {
329         if (v.layoutParams == null) {
330             v.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
331         }
332         addView(v)
333     }
334 }