Recently Written · git

ink-recents

Ink Recents: a recent-apps switcher drawn for e-ink readers. No animation, swipe up to close, swipe down to close the others.

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

Log | Files | Refs


app/src/main/java/dev/equwal/inkrecents/Ui.kt (11928 bytes)

1 // SPDX-License-Identifier: GPL-3.0-or-later
2 // Copyright (c) 2026 equwal
3 package dev.equwal.inkrecents
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.
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     /** Screen title, for a screen that draws no top bar. */
139     fun LinearLayout.title(text: String) = add(TextView(context).apply {
140         this.text = text
141         setTextColor(INK)
142         setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f)
143         setTypeface(Typeface.DEFAULT_BOLD)
144         setPadding(0, context.dp(4), 0, context.dp(8))
145     })
146 
147     fun LinearLayout.header(text: String) = add(TextView(context).apply {
148         this.text = text.uppercase()
149         setTextColor(DIM)
150         setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
151         setTypeface(Typeface.DEFAULT_BOLD)
152         letterSpacing = 0.08f
153         setPadding(0, context.dp(24), 0, context.dp(8))
154     })
155 
156     fun LinearLayout.note(text: String) = add(TextView(context).apply {
157         this.text = text
158         setTextColor(DIM)
159         setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
160         setLineSpacing(context.dp(3).toFloat(), 1f)
161         setPadding(0, 0, 0, context.dp(8))
162     })
163 
164     fun LinearLayout.rule() = add(View(context).apply {
165         setBackgroundColor(RULE)
166         layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 1)
167     })
168 
169     // ---- rows --------------------------------------------------------------
170 
171     /**
172      * A row: what it is, what it does now, and either a state chip or a chevron
173      * on the right. The whole row is the touch target.
174      */
175     fun LinearLayout.row(
176         title: String,
177         subtitle: String? = null,
178         enabled: Boolean = true,
179         state: String? = null,
180         onClick: (() -> Unit)? = null
181     ): LinearLayout {
182         val box = LinearLayout(context).apply {
183             orientation = LinearLayout.HORIZONTAL
184             gravity = Gravity.CENTER_VERTICAL
185             minimumHeight = context.dp(56)
186             val v = context.dp(10)
187             setPadding(0, v, 0, v)
188             isClickable = onClick != null && enabled
189             if (isClickable) setOnClickListener { onClick?.invoke() }
190         }
191         val texts = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
192         texts.addView(TextView(context).apply {
193             text = title
194             setTextColor(if (enabled) INK else DIM)
195             setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f)
196         })
197         if (!subtitle.isNullOrBlank()) {
198             texts.addView(TextView(context).apply {
199                 text = subtitle
200                 setTextColor(DIM)
201                 setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
202                 setPadding(0, context.dp(2), 0, 0)
203             })
204         }
205         box.addView(texts, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
206 
207         if (state != null) {
208             box.addView(chip(context, state).apply {
209                 layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
210                     marginStart = context.dp(8)
211                 }
212             })
213         } else if (box.isClickable) {
214             box.addView(Glyph(context, Glyph.CHEVRON).apply {
215                 layoutParams = LinearLayout.LayoutParams(context.dp(24), context.dp(24)).apply {
216                     marginStart = context.dp(8)
217                 }
218             })
219         }
220         add(box)
221         rule()
222         return box
223     }
224 
225     // ---- buttons -----------------------------------------------------------
226 
227     /**
228      * A button with the platform look taken off: our own outline, no elevation,
229      * and no ripple, which smears on an e-ink panel.
230      */
231     fun LinearLayout.button(text: String, onClick: () -> Unit) = add(
232         Button(context).apply {
233             this.text = text
234             isAllCaps = false
235             gravity = Gravity.CENTER
236             setTextColor(INK)
237             setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
238             setTypeface(Typeface.DEFAULT_BOLD)
239             background = outline(context)
240             stateListAnimator = null
241             elevation = 0f
242             minWidth = 0
243             minimumWidth = 0
244             minHeight = 0
245             minimumHeight = 0
246             setPadding(context.dp(16), 0, context.dp(16), 0)
247             layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, context.dp(48)).apply {
248                 topMargin = context.dp(8)
249                 bottomMargin = context.dp(8)
250             }
251             setOnClickListener { onClick() }
252         }
253     )
254 
255     // ---- drawing -----------------------------------------------------------
256 
257     /** A black outline on a fill: the one shape this app is built from. */
258     fun outline(c: Context, fill: Int = Color.WHITE, radius: Int = 2): GradientDrawable =
259         GradientDrawable().apply {
260             shape = GradientDrawable.RECTANGLE
261             setColor(fill)
262             cornerRadius = c.dp(radius).toFloat()
263             setStroke(c.px(1), INK)
264         }
265 
266     /** A short state word, in an outlined box. */
267     fun chip(c: Context, text: String): TextView = TextView(c).apply {
268         this.text = text
269         setTextColor(INK)
270         setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
271         setTypeface(Typeface.DEFAULT_BOLD)
272         background = outline(c, radius = 10)
273         setPadding(c.dp(8), c.dp(3), c.dp(8), c.dp(3))
274         maxLines = 1
275     }
276 
277     /**
278      * The back arrow and the row chevron. They are drawn, not typed, because a
279      * firmware font can be without the characters.
280      */
281     private class Glyph(c: Context, private val kind: Int) : View(c) {
282 
283         private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
284             color = INK
285             style = Paint.Style.STROKE
286             strokeWidth = c.resources.displayMetrics.density * 2f
287             strokeCap = Paint.Cap.ROUND
288             strokeJoin = Paint.Join.ROUND
289         }
290         private val path = Path()
291 
292         override fun onDraw(canvas: Canvas) {
293             val cx = width / 2f
294             val cy = height / 2f
295             path.reset()
296             if (kind == BACK) {
297                 val r = minOf(width, height) * 0.24f
298                 val h = r * 0.62f
299                 path.moveTo(cx + r, cy)
300                 path.lineTo(cx - r, cy)
301                 path.moveTo(cx - r + h, cy - h)
302                 path.lineTo(cx - r, cy)
303                 path.lineTo(cx - r + h, cy + h)
304             } else {
305                 val w = minOf(width, height) * 0.17f
306                 val h = w * 1.7f
307                 path.moveTo(cx - w, cy - h)
308                 path.lineTo(cx + w, cy)
309                 path.lineTo(cx - w, cy + h)
310             }
311             canvas.drawPath(path, paint)
312         }
313 
314         companion object {
315             const val BACK = 0
316             const val CHEVRON = 1
317         }
318     }
319 
320     /** Full width by default. A caller that wants otherwise sets the params first. */
321     private fun LinearLayout.add(v: View) {
322         if (v.layoutParams == null) {
323             v.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
324         }
325         addView(v)
326     }
327 }