diff --git a/CLAUDE.md b/CLAUDE.md index b833ae8..14f49de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,6 +274,28 @@ REG_WAIT timeout, including boots that never froze; Sunshine was already on `vaapi` and had quit beforehand; and the Quest is not an MTP device in adb mode, so gvfs never touches it. +**A service's environment is not a terminal's, and two features died of it.** +`systemd --user` starts the server at login with the bare system PATH and with +no `DISPLAY` or `WAYLAND_DISPLAY` — the graphical session imports those *after* +it is already running. So `installed()` could not see `claude`, `codex` or +anything else in `~/.local/bin` and the bar stopped offering them, and the tray +decided there was no desktop and never showed an icon at all. Both worked +perfectly when the server was started from a terminal, which is how they +survived. Ask the shell for the PATH (`$SHELL -ic`, the mode a session is opened +in) and ask the bus for a tray host, not the environment. +[`docs/service.md`](docs/service.md) has the commands that tell the two apart. + +**A drawn glyph lives in the key's background, and swapping that background +takes it with it.** `Buttons.centre()` layers a `Glyphs` drawable over whatever +background the key has, because a compound drawable is laid out beside the text +and lands off-centre on a key with no text. Anything that later replaces the +background — the rocker halves do — empties the key. `Buttons` therefore keeps +the glyph in a `WeakHashMap` so a replacement can put it back. + +The obvious fix is a foreground, and it does not work: `setForeground` on these +`TextView`s is simply not drawn, which emptied every arrow and the backspace key +on the keypad while looking like the correct answer. Do not try it again. + **Signal dispositions survive `exec`.** An earlier server set `SIGCHLD` to `SIG_IGN` to avoid zombies; every descendant that reaps its own children then got `ECHILD` from `waitpid`, and Claude Code's hooks failed with exactly that. diff --git a/client/app/NOTICE.md b/client/app/NOTICE.md index 29de6e7..bca680b 100644 --- a/client/app/NOTICE.md +++ b/client/app/NOTICE.md @@ -1,4 +1,24 @@ -# Third-party code in this module +# Third-party code and assets in this module + +## Fonts + +`src/main/assets/DejaVuSansMono.ttf` — the [DejaVu fonts](https://dejavu-fonts.github.io/), +under the Bitstream Vera licence with the DejaVu changes under the same terms. +Both permit redistribution, including in a bundled application. + +It is here for a specific reason: the thresholds in `docs/readability.md` were +measured with this face, so a client drawing with anything else cannot be judged +against them. The system alias `Typeface.MONOSPACE` resolves on Horizon OS to +`DroidSansMono.ttf`, a face drawn for Android 2 that is neither what was measured +nor what a terminal looks like. + +`src/main/assets/JetBrainsMono-Regular.ttf` — [JetBrains Mono](https://www.jetbrains.com/lp/mono/), +SIL Open Font Licence 1.1. Carried alongside DejaVu while the two are being +compared in the headset; whichever loses should be deleted rather than kept. + +`src/main/assets/MaterialIcons-Regular.ttf` — Material Icons, Apache 2.0. + +## Emulator `src/main/java/com/termux/terminal/` and `src/main/java/com/termux/view/TerminalRenderer.java` come from [termux/termux-app](https://github.com/termux/termux-app), whose diff --git a/client/app/build.gradle.kts b/client/app/build.gradle.kts index 5619197..6220f91 100644 --- a/client/app/build.gradle.kts +++ b/client/app/build.gradle.kts @@ -51,6 +51,20 @@ android { signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug") } + + // A build for wearing, not for shipping: the debug variant's contents, + // signed with the release key. + // + // It exists because Android refuses an update signed by a different key, and + // the headset already carries a properly signed install. The alternative was + // to uninstall it, which throws away the paired servers and their tokens and + // means pairing every machine again to look at a change. + create("concept") { + initWith(getByName("debug")) + isDebuggable = true + signingConfig = signingConfigs.findByName("release") + ?: signingConfigs.getByName("debug") + } } compileOptions { diff --git a/client/app/src/main/AndroidManifest.xml b/client/app/src/main/AndroidManifest.xml index 0889751..acabb2c 100644 --- a/client/app/src/main/AndroidManifest.xml +++ b/client/app/src/main/AndroidManifest.xml @@ -74,6 +74,22 @@ android:label="terminal bar" android:windowSoftInputMode="adjustResize|stateHidden" android:configChanges="density|keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|uiMode"> + The five skins were built to be argued with — a decision is only a decision + * once it has been compared against something — and that comparison used to live + * in a debug gallery that shipped in no release. The gallery is gone, and taking + * it away removed the only way to see the alternatives at all. This is the same + * choice offered where it belongs: inside the product, to the person wearing it, + * and not to a build variant. + * + *

The choice is stored rather than held, because a panel that forgets is worse + * than a panel with one look. The bar is recreated whenever the shell restarts it + * — resized, reopened, or after the headset sleeps — and a look that lasted until + * then would read as the app losing its settings. + * + *

Whether the alternatives should exist at all is a separate question from + * whether they should be reachable. CONSOLE remains the default and the shipped + * arrangement; see {@link Buttons#skin()}. + */ +final class Appearance { + + private static final String PREFS = "appearance"; + private static final String KEY = "skin"; + + /** One entry in the menu: what it is called and how to build it. */ + static final class Choice { + final String id; + final String name; + final String note; + + Choice(String id, String name, String note) { + this.id = id; + this.name = name; + this.note = note; + } + + Skin skin() { + switch (id) { + case "capped": + return Skin.capped(); + case "outline": + return Skin.outline(); + case "machined": + return Skin.machined(); + case "tactile": + return Skin.tactile(); + default: + return Skin.console(); + } + } + } + + /** + * In the order they were built, which is also the order they get further from + * a flat interface and closer to a panel. + */ + static final Choice[] CHOICES = { + new Choice("console", "Console", "read left, press middle, rail right"), + new Choice("tactile", "Tactile", "moulded caps on a dark deck"), + new Choice("machined", "Machined", "milled plate, deep screens"), + new Choice("outline", "Outline", "no material at all"), + new Choice("capped", "Capped", "the first shallow cap"), + }; + + private Appearance() { + } + + static String chosen(Context context) { + return prefs(context).getString(KEY, CHOICES[0].id); + } + + static Choice current(Context context) { + String id = chosen(context); + for (Choice choice : CHOICES) { + if (choice.id.equals(id)) return choice; + } + return CHOICES[0]; + } + + /** + * Put the stored choice into effect. Called before anything is drawn, from + * every entry point that builds views, because {@link Buttons} reads the skin + * at build time and there is no going back over a view that already exists. + */ + static void restore(Context context) { + Buttons.applySkin(current(context).skin()); + } + + /** Remember a choice and apply it. The caller redraws. */ + static void choose(Context context, Choice choice) { + prefs(context).edit().putString(KEY, choice.id).apply(); + Buttons.applySkin(choice.skin()); + } + + private static SharedPreferences prefs(Context context) { + return context.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/Buttons.java b/client/app/src/main/java/dev/butschster/linuxterminal/Buttons.java index 9a1f37a..e6c1026 100644 --- a/client/app/src/main/java/dev/butschster/linuxterminal/Buttons.java +++ b/client/app/src/main/java/dev/butschster/linuxterminal/Buttons.java @@ -39,27 +39,80 @@ public class Buttons { public static final int VOICE = 5; public static final int ARROW = 6; + /** + * Which set of colours and shapes the whole bar is currently drawn from. + * + *

Static, and read at the moment a view is built rather than captured once: + * every screen in the app builds its keys through this class, so swapping the + * skin and rebuilding is enough to see the whole app in the other treatment. + * That is what makes them comparable at all — a redesign of the bar alone, + * with the connection manager still in the old palette, compares nothing. + * + *

The default is what the app ships as. It is CONSOLE: the arrangement that + * came out of the concept work and the reviews, chosen over the other four + * because it is the only one whose difference is structural rather than + * decorative — read on the left, press in the middle, the app's own controls + * on a rail. The rest stay in {@link Skin} because they are how this one is + * argued with, and because a skin that cannot be compared against anything + * stops being a decision and becomes a habit. + */ + private static Skin skin = Skin.console(); + + public static void applySkin(Skin chosen) { + skin = chosen; + FILL = chosen.fill; + TEXT = chosen.text; + MUTED = chosen.muted; + RULE = chosen.rule; + } + + public static Skin skin() { + return skin; + } + + /** + * Whether this skin's controls are drawn as physical objects — a moulded cap + * with an extrusion under it, and rockers pitched like a roof. + * + *

Two skins share the treatment and differ in their numbers, which is the + * arrangement worth having: the recipe lives in one place and a skin says how + * tall, how round and how far it travels. Asking for the kind by name at each + * call site is how the two would drift apart. + */ + private static boolean physicalSkin() { + return skin.kind == Skin.Kind.TACTILE || skin.kind == Skin.Kind.CONSOLE; + } + + /** + * The surface a group of keys is mounted on: the dynamic half's plate, or the + * slightly different one under the fixed half. + * + *

A drawable rather than a colour because a lit skin needs the plate lit + * too — see {@link Deck}. On the skins that have no material it is exactly the + * flat fill it always was. + */ + public static Drawable deck(boolean fixedHalf) { + int colour = fixedHalf ? skin.bgPanel : skin.bg; + return new Deck(colour, skin.textured); + } + // Four roles, not eight decorative shades. The previous palette had two greys // sixteen units apart, which through pancake lenses is one grey. - private static final int[] FILL = { - Color.rgb(58, 60, 70), // KEY — a literal keystroke - Color.rgb(52, 58, 74), // DESTINATION — somewhere to go - Color.rgb(40, 82, 128), // COMMAND — a line that runs - Color.rgb(122, 58, 30), // WARN — destructive - Color.rgb(38, 86, 62), // ENTER - Color.rgb(52, 118, 84), // VOICE - Color.rgb(74, 80, 104), // ARROW — the cluster the hand finds without looking - }; + private static int[] FILL = skin.fill; - public static final int TEXT = Color.rgb(224, 226, 232); - public static final int MUTED = Color.rgb(150, 155, 168); + // Not final, because the skin replaces them. Anything reading these must read + // them while building a view, never copy them into a constant of its own — a + // static final int initialised from one of these is inlined by the compiler + // and would keep the colour it was compiled with. + public static int TEXT = skin.text; + public static int MUTED = skin.muted; /** - * Every line in the bar, horizontal or vertical. One constant because they are + * Every line in the bar, horizontal or vertical. One value because they are * one idea: the rules between groups and the divider between the panels have to * be the same weight and the same colour, or the bar looks like two designs. */ - public static final int RULE = Color.rgb(52, 54, 64); + public static int RULE = skin.rule; /** * The keyboard mark, from the bundled Material font. @@ -82,6 +135,16 @@ public class Buttons { private final Handler handler = new Handler(Looper.getMainLooper()); private final Typeface icons; + /** + * The drawn glyph on each key that has one, kept so a background can be + * replaced without losing it. Weak, because the bar rebuilds its left half + * whenever the foreground process changes and nothing should outlive that. + */ + private final java.util.Map glyphs = new java.util.WeakHashMap<>(); + + /** The role each key was built with, so a cluster can rebuild its face. */ + private final java.util.Map styles = new java.util.WeakHashMap<>(); + private int textDp = 15; private int padH = 11; private int padV = 8; @@ -137,23 +200,635 @@ public int iconSizeDp() { public TextView key(String label, int style, String hint, View.OnClickListener onClick) { TextView view = new TextView(context); view.setText(label); - view.setTextColor(TEXT); + view.setTextColor(labelColour(style)); view.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textDp); - view.setTypeface(Typeface.MONOSPACE); + view.setTypeface(Fonts.mono(context)); + view.setSingleLine(true); + view.setGravity(Gravity.CENTER); + // The legend is centred in the cap, not in the cell. + // + // A physical key hands the bottom of its cell to the extrusion under it, so + // the cap occupies the upper part and text centred on the whole cell sits + // visibly low — worst on the short chips, where the extrusion is a fifth of + // the height. Padding the bottom by the extrusion's depth moves the text + // back to the middle of the thing it is written on. + int sink = physicalSkin() ? dp(skin.depthDp) : 0; + view.setPadding(dp(padH), dp(padV), dp(padH), dp(padV) + sink); + view.setMinWidth(dp(minWidth)); + view.setMinHeight(dp(minHeight) + sink); + view.setBackground(background(style)); + styles.put(view, style); + if (skin.engrave) engrave(view); + travel(view); + view.setClickable(true); + view.setFocusable(false); + debounced(view, onClick); + if (hint != null && !hint.isEmpty()) view.setContentDescription(hint); + return view; + } + + /** + * The glass of a display, sunk into the plate. + * + *

Deeper than a key stands proud — 4dp against 3 — because a recess is read + * from its shaded wall and a wall thinner than the corner radius disappears + * into the rounding. The radius itself is the key's, so the two shapes belong + * to one panel. + */ + /** + * How thick the wall of a screen is. Three device-independent pixels, always, + * and deliberately not derived from how far the keys stand proud. + * + *

It used to be {@code depthDp + 1}, which is defensible on a skin whose + * keys lift 3dp and absurd on one whose keys lift 7: the console skin drew an + * 8dp wall around every screen, and what should have read as a pane of glass + * read as a picture frame. A recess only needs enough wall to shade. + */ + private static final int SCREEN_WALL_DP = 3; + + public Drawable display() { + return screen(dp(screenCornerDp())); + } + + /** + * A screen with square corners, for one that scrolls. + * + *

A rounded screen and a scrollbar do not get along: the bar runs down the + * straight part of the edge and then the corner curves away from underneath + * it, which reads as the bar hanging off the glass. A listing is the one place + * on this panel where the content is longer than the window, so it is the one + * place the corner has to go. + */ + public Drawable squareDisplay() { + return screen(0f); + } + + private Drawable screen(float radiusPx) { + Display display = new Display(skin.glass, dp(SCREEN_WALL_DP), radiusPx, + skin.screen, skin.accent, skin.crt); + // A frame in the phosphor's own family, not in the panel's. The glass + // belongs to the display, and a grey frame would attach it to the plate. + return skin.crt ? display.withBezel(0xFF173425) : display; + } + + /** + * A screen's corner. Smaller than the section holding it, because glass is cut + * and a moulded housing is not: 10dp against the section's 12 is the same + * relationship a real panel has between a bezel and the plate around it. + */ + public int screenCornerDp() { + return Math.min(10, containerCornerDp()); + } + + /** + * A command chip: shorter and tighter than a key, because it is a line to run + * rather than a key to hold down. + * + *

The distinction is worth drawing. A keypad key is pressed by feel and + * repeatedly; a chip is read first and pressed once. Making them the same size + * would spend the panel's most valuable space — the reachable middle — on + * things that are chosen with the eyes anyway. + */ + public TextView chip(String label, boolean danger, View.OnClickListener onClick) { + TextView view = key(label, danger ? WARN : KEY, null, onClick); + view.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14f); + // The same compensation `key` applies, restated because this overrides the + // padding: a physical cap gives the bottom of its cell to the extrusion, so + // text centred on the whole cell sits low on the cap. Worst exactly here, + // where the extrusion is a fifth of the height. + int sink = physicalSkin() ? dp(skin.depthDp) : 0; + view.setPadding(dp(12), dp(5), dp(12), dp(5) + sink); + view.setMinHeight(dp(36) + sink); + view.setMinWidth(0); + return view; + } + + /** The selection bar behind a row of a listing. */ + public Drawable rowSelection() { + return selection(); + } + + /** The recessed region a group of controls is mounted in. */ + /** + * The radius a container takes so that what sits inside it looks concentric. + * + *

A box and the thing mounted in it cannot share a radius: with equal + * corners the gap between them is widest exactly at the corner, and the inner + * part looks pushed into a corner it does not fit. The container's radius is + * the inner one plus the space between them, which keeps the gap even the + * whole way round. One rule, so every housing on the panel obeys it. + */ + public int containerCornerDp() { + return skin.cornerDp + 3; + } + + public Drawable section() { + return section(false); + } + + /** + * A milled region. {@code fixed} marks the housings whose contents never + * change with context — the keypad, the rail — against the ones the host + * refills as you work. + * + *

Two things are fixed here that were both wrong before. The plate was + * 0xFF0D141E against a panel of 0xFF111824: four units per channel, under 2% + * of luminance, which through pancake lenses is the same colour — so the box + * was paid for entirely by its outline. And a fixed housing looked exactly + * like a dynamic one, which gave the hand no way to learn that one of them is + * furniture and the other is weather. + */ + public Drawable section(boolean fixed) { + Section plate = new Section(fixed ? 0xFF0A1119 : 0xFF070C12, dp(containerCornerDp())); + return fixed ? plate.lit() : plate; + } + + /** + * The launch key: Enter, drawn as the one control that is not a member of the + * grid. See {@link LaunchKey} for why it earns a shape of its own. + */ + public View launchKey(String label, String hint, View.OnClickListener onClick) { + LinearLayout centred = new LinearLayout(context); + centred.setOrientation(LinearLayout.HORIZONTAL); + centred.setGravity(Gravity.CENTER); + + float radius = dp(Math.max(6, skin.cornerDp)); + float throwPx = dp(6); + + // Two views, because two things happen on press and only one of them is + // the background: the cap goes down and the pocket does not. The legend is + // drawn by the view, so the view is what has to travel — a cap that moved + // inside its own background would slide out from under its own label, + // which is exactly what it did. + LinearLayout retainer = new LinearLayout(context); + retainer.setOrientation(LinearLayout.HORIZONTAL); + retainer.setGravity(Gravity.CENTER); + retainer.setBackground(new Keycap(Keycap.Part.RETAINER, FILL[ENTER], + skin.edge[ENTER], skin.bgPanel, radius, throwPx, false)); + int pad = dp(5); + retainer.setPadding(pad, pad, pad, pad); + retainer.setClickable(true); + retainer.setFocusable(false); + debounced(retainer, onClick); + if (hint != null && !hint.isEmpty()) retainer.setContentDescription(hint); + + TextView cap = new TextView(context); + cap.setText(label.toUpperCase(java.util.Locale.ROOT)); + cap.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14f); + cap.setTypeface(Fonts.mono(context)); + cap.setLetterSpacing(0.10f); + cap.setSingleLine(true); + cap.setGravity(Gravity.CENTER); + cap.setTextColor(skin.legend[ENTER]); + cap.setPadding(dp(20), dp(11), dp(18), dp(11) + dp(6)); + cap.setMinWidth(dp(150)); + // It takes the retainer's pressed state, so the cap lights and travels + // when the thing you actually aimed at is pressed. + cap.setDuplicateParentStateEnabled(true); + + StateListDrawable states = new StateListDrawable(); + states.addState(new int[]{android.R.attr.state_pressed}, + new Keycap(Keycap.Part.CAP, FILL[ENTER], skin.edge[ENTER], skin.bgPanel, + radius, throwPx, true)); + states.addState(new int[]{android.R.attr.state_hovered}, + new Keycap(Keycap.Part.CAP, lighten(FILL[ENTER], 0.10f), skin.accentGo, + skin.bgPanel, radius, throwPx, false)); + states.addState(new int[]{}, + new Keycap(Keycap.Part.CAP, FILL[ENTER], skin.edge[ENTER], skin.bgPanel, + radius, throwPx, false)); + cap.setBackground(states); + + // The travel itself: the whole cap, legend included, six pixels down. + android.animation.StateListAnimator moving = new android.animation.StateListAnimator(); + moving.addState(new int[]{android.R.attr.state_pressed}, + android.animation.ObjectAnimator.ofFloat(cap, "translationY", throwPx) + .setDuration(90)); + moving.addState(new int[]{}, + android.animation.ObjectAnimator.ofFloat(cap, "translationY", 0f) + .setDuration(70)); + cap.setStateListAnimator(moving); + + cap.setCompoundDrawablesWithIntrinsicBounds(null, null, + Glyphs.drawable(context, Glyphs.Kind.ENTER, skin.legend[ENTER], iconSizeDp()), + null); + cap.setCompoundDrawablePadding(dp(12)); + + retainer.addView(cap, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + centred.addView(retainer, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + return centred; + } + + /** + * Several keys in one housing, touching, divided by a seam. + * + *

This is the third way of putting controls together and it is neither of + * the others. Loose keys with a gap between them are separate objects. A + * rocker is one pitched cap where pressing an end tilts the whole thing — one + * control, two directions. A cluster is what a keyboard does: the keys are + * individually pressable and individually flat, but they are set into one + * cut-out with no space between them, so the eye takes them as a block while + * the hand still finds each cap. + * + *

Drawn by rounding each cap only where it faces the outside world and + * leaving it square where it meets its neighbour, with two device pixels of + * housing showing through as the seam. That is the whole trick: the outer + * silhouette is one rounded rectangle and every internal edge is a straight + * line, which is what a moulded key block looks like from above. + * + *

Use it where the members are the same kind of thing and are reached for + * together — the arrow keys, or dictation beside the keyboard. Controls that + * merely sit near each other keep their gap. + */ + public LinearLayout cluster(boolean vertical, View... cells) { + LinearLayout housing = new LinearLayout(context); + housing.setOrientation(vertical ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL); + housing.setBackground(section(true)); + int pad = dp(3); + housing.setPadding(pad, pad, pad, pad); + + float r = dp(skin.cornerDp); + for (int i = 0; i < cells.length; i++) { + View cell = cells[i]; + boolean first = i == 0; + boolean last = i == cells.length - 1; + + float tl = first ? r : 0; + float tr = (vertical ? first : last) ? r : 0; + float br = last ? r : 0; + float bl = (vertical ? last : first) ? r : 0; + float[] radii = {tl, tl, tr, tr, br, br, bl, bl}; + + Integer style = styles.get(cell); + if (style != null) { + cell.setBackground(clusteredFace(style, radii, glyphs.get(cell))); + } + + LinearLayout.LayoutParams params = vertical + ? new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f) + : new LinearLayout.LayoutParams( + 0, LinearLayout.LayoutParams.MATCH_PARENT, 1f); + // The seam: two pixels of the housing showing between neighbours. + if (!first) { + if (vertical) { + params.topMargin = dp(2); + } else { + params.setMarginStart(dp(2)); + } + } + housing.addView(cell, params); + } + return housing; + } + + /** + * The arrow block: one key above, several below, one cut-out around all of + * them. + * + *

The caller passes the row that already exists — the up key with whatever + * sits either side of it — and the keys that go beneath. Nothing moves: the + * cells keep their columns and their widths, and only the housing under them + * changes, which is the difference between grouping and rearranging. + */ + public LinearLayout tee(View[] topRow, int stemIndex, View... bottom) { + LinearLayout block = new LinearLayout(context); + block.setOrientation(LinearLayout.VERTICAL); + + int columns = topRow.length; + float left = (stemIndex + 0.02f) / columns; + float right = (stemIndex + 0.98f) / columns; + block.setBackground(new TeeHousing(0xFF0A1119, dp(skin.cornerDp) + dp(3), + left, right, 0.5f)); + + float r = dp(skin.cornerDp); + LinearLayout top = new LinearLayout(context); + top.setOrientation(LinearLayout.HORIZONTAL); + for (int i = 0; i < columns; i++) { + View cell = topRow[i]; + if (i == stemIndex) { + // Rounded above, square below: the stem runs on into the arm. + float[] radii = {r, r, r, r, 0, 0, 0, 0}; + Integer style = styles.get(cell); + if (style != null) { + cell.setBackground(clusteredFace(style, radii, glyphs.get(cell))); + } + } + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + int inset = i == stemIndex ? dp(3) : gap() / 2; + params.setMargins(inset, i == stemIndex ? dp(3) : gap() / 2, inset, 0); + top.addView(cell, params); + } + block.addView(top, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + for (int i = 0; i < bottom.length; i++) { + View cell = bottom[i]; + boolean first = i == 0; + boolean last = i == bottom.length - 1; + + // A corner is rounded when both of its sides face the outside world, + // and square when either one meets a neighbour. + // + // Under the arm of the tee that gives the answer by itself: the left + // key has nothing above it — the stem is over the middle one — so its + // top-left is as external as its bottom-left and rounds with it. Only + // the centre key is square all round, because it has a neighbour on + // three sides. The first version rounded the bottom row along its + // bottom edge alone, which left the outer keys with a square shoulder + // above a round foot and no reason for the difference. + boolean stemAbove = i == stemIndex; + float tl = first ? r : 0; + float tr = last ? r : 0; + float br = last ? r : 0; + float bl = first ? r : 0; + if (stemAbove) { + tl = 0; + tr = 0; + } + float[] radii = {tl, tl, tr, tr, br, br, bl, bl}; + Integer style = styles.get(cell); + if (style != null) { + cell.setBackground(clusteredFace(style, radii, glyphs.get(cell))); + } + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + params.setMargins(first ? dp(3) : dp(2), dp(2), last ? dp(3) : 0, dp(3)); + row.addView(cell, params); + } + block.addView(row, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + + // Twice the ordinary gap beneath the block. What follows it is Enter, and + // a cluster that ends flush against the next control reads as five keys in + // one housing rather than four — the seam that means "same block" and the + // gap that means "different control" have to differ by more than a pixel. + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + params.bottomMargin = gap(); + block.setLayoutParams(params); + return block; + } + + /** + * Square off one edge of a key so it can meet a cluster on that side. + * + *

For the arrow cluster: the up key lives in the row above, between two + * unrelated keys, and cannot join the housing without moving — which this + * keypad does not do for appearance. Squaring its lower corners and closing + * the gap beneath it gets the inverted T that every keyboard has, while every + * key stays in the cell it has always occupied. + */ + public void joinBelow(View key) { + Integer style = styles.get(key); + if (style == null) return; + float r = dp(skin.cornerDp); + float[] radii = {r, r, r, r, 0, 0, 0, 0}; + key.setBackground(clusteredFace(style, radii, glyphs.get(key))); + } + + private StateListDrawable clusteredFace(int style, float[] radii, Drawable glyph) { + if (!physicalSkin()) return flatClusteredFace(style, radii, glyph); + + StateListDrawable states = new StateListDrawable(); + float depth = dp(skin.depthDp); + int face = FILL[style]; + int border = skin.edge[style]; + int legend = skin.legend[style]; + + states.addState(new int[]{android.R.attr.state_pressed}, + withGlyph(new PhysicalKey(face, border, legend, 0, depth, true, 0) + .corners(radii), glyph)); + states.addState(new int[]{android.R.attr.state_hovered}, + withGlyph(new PhysicalKey(lighten(face, 0.06f), + blend(border, skin.accentNav, 0.6f), legend, 0, depth, false, + skin.accentNav).corners(radii), glyph)); + states.addState(new int[]{}, + withGlyph(new PhysicalKey(face, border, legend, 0, depth, false, 0) + .corners(radii), glyph)); + return states; + } + + /** + * A cluster cell on a skin that has no moulded cap. + * + *

Grouping is not a property of one skin. A block of keys in a single + * cut-out is a statement about what the controls are — reached for together, + * pressed separately — and that is true whether the panel is drawn as + * machined metal or as flat plates. So the shape is built here from the same + * per-corner radii, using whatever the skin's own fill and edge are: the + * silhouette is the same everywhere, the material is not. + */ + private StateListDrawable flatClusteredFace(int style, float[] radii, Drawable glyph) { + StateListDrawable states = new StateListDrawable(); + int face = FILL[style]; + states.addState(new int[]{android.R.attr.state_pressed}, + withGlyph(flatFace(lighten(face, 0.35f), radii, style), glyph)); + states.addState(new int[]{android.R.attr.state_hovered}, + withGlyph(flatFace(lighten(face, 0.18f), radii, style), glyph)); + states.addState(new int[]{}, withGlyph(flatFace(face, radii, style), glyph)); + return states; + } + + private GradientDrawable flatFace(int fill, float[] radii, int style) { + GradientDrawable shape = new GradientDrawable(); + shape.setColor(fill); + shape.setCornerRadii(radii); + if (skin.border > 0) shape.setStroke(dp(skin.border), skin.edge[style]); + return shape; + } + + private Drawable withGlyph(Drawable face, Drawable glyph) { + return glyph == null ? face : stack(face, glyph); + } + + /** + * A line inside a display: lit text on the glass, with no cap of its own. + * + *

Selection is an inversion — the phosphor fills the row and the text goes + * dark — rather than a highlight laid over it. That is what a character cell + * display does when it selects, it is what the terminal underneath this bar + * does, and it is the only "pressed" that does not imply the row moved. + */ + public TextView readout(String label, String hint, View.OnClickListener onClick) { + TextView view = new TextView(context); + view.setText(label); + view.setTextColor(skin.phosphor); + view.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textDp); + view.setTypeface(Fonts.mono(context)); view.setSingleLine(true); view.setGravity(Gravity.CENTER); view.setPadding(dp(padH), dp(padV), dp(padH), dp(padV)); view.setMinWidth(dp(minWidth)); view.setMinHeight(dp(minHeight)); - view.setBackground(background(FILL[style])); - engrave(view); + view.setBackground(selection()); + if (skin.crt) { + // A little bloom, the way a phosphor spreads past the beam. Small + // enough to soften the edge of a stroke and not enough to close the + // counters — that is the line between "lit" and "out of focus". + // Back up to the reference's 4px at 35%, having been halved once and + // then found too faint to read as lit at all. + // + // The earlier complaint was real but was about the wrong number: a + // wide, strong bloom lands on the stroke and thickens the letter. A + // wider, weaker one lands around it and does not. So the radius goes + // up and the alpha stays low — spread without weight, which is what a + // phosphor actually does. + // text-shadow: 0 0 6px rgba(82,255,125,.5) — the selected row's value + // from the reference sheet, which is the brighter of the two it gives. + // .5 alpha is 128 of 255. The colour comes from this skin's own + // phosphor rather than the sheet's, so a skin that changes its tube + // changes its bloom with it. + view.setShadowLayer(dp(6), 0f, 0f, Color.argb(128, Color.red(skin.phosphor), + Color.green(skin.phosphor), Color.blue(skin.phosphor))); + } view.setClickable(true); view.setFocusable(false); debounced(view, onClick); if (hint != null && !hint.isEmpty()) view.setContentDescription(hint); + + glowOnHover(view, view, skin.phosphor); return view; } + /** + * A row in a listing: the full width of the screen, a cursor mark, and the + * name after it. + * + *

This is the difference between an embedded display and a dark toolbar. A + * listing is one control with rows — the eye tracks down a single column, and + * the highlight is a bar across the row, which is what every terminal file + * browser has done since before any of this was on a headset. Separate cells + * would be N controls that happen to be inside a rectangle. + */ + public TextView row(String label, String hint, View.OnClickListener onClick) { + TextView view = readout(" " + label, hint, onClick); + view.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + view.setPadding(dp(10), dp(6), dp(10), dp(6)); + view.setMinHeight(dp(minHeight - 6)); + // A column wide enough for most names, so the rows below line up into + // columns rather than into a ragged left edge with holes in it. A name + // longer than this takes the space it needs and the column after it + // starts later, which is exactly what `ls` does. + view.setMinWidth(dp(150)); + return view; + } + + /** + * What a row on a screen does when the ray is on it, and when it is pressed. + * + *

Nothing fills. A row is text on glass, and painting a rectangle behind it + * turns it into a button — which is exactly the thing this layer exists not to + * be. Under the ray the phosphor simply burns brighter, which is what a cursor + * does on a character display and costs no new shape. Pressed, the row takes a + * thin outline: the beam is now drawing a box, and a box around a line is how + * a terminal has always said "this one". + */ + private StateListDrawable selection() { + StateListDrawable states = new StateListDrawable(); + + GradientDrawable pressed = new GradientDrawable(); + pressed.setColor(Color.TRANSPARENT); + pressed.setCornerRadius(dp(skin.cornerDp)); + pressed.setStroke(dp(1), Color.argb(190, Color.red(skin.phosphor), + Color.green(skin.phosphor), Color.blue(skin.phosphor))); + + GradientDrawable idle = new GradientDrawable(); + idle.setColor(Color.TRANSPARENT); + + states.addState(new int[]{android.R.attr.state_pressed}, pressed); + states.addState(new int[]{}, idle); + return states; + } + + /** + * The key physically enters its housing when pressed. + * + *

A {@link android.animation.StateListAnimator} rather than a second + * drawable: the drawable can only redraw the face, and what is wanted is the + * whole cap — legend included — moving down. 90ms is inside the 80–120ms a + * real key takes to bottom out, and the release is faster than the press + * because a spring returns quicker than a finger pushes. + */ + private void travel(View view) { + if (skin.travelDp <= 0) return; + android.animation.StateListAnimator states = new android.animation.StateListAnimator(); + states.addState(new int[]{android.R.attr.state_pressed}, + android.animation.ObjectAnimator.ofFloat(view, "translationY", dp(skin.travelDp)) + .setDuration(90)); + states.addState(new int[]{}, + android.animation.ObjectAnimator.ofFloat(view, "translationY", 0f) + .setDuration(70)); + view.setStateListAnimator(states); + } + + /** The same phosphor with the beam turned up: towards white, not another hue. */ + public static int brighten(int colour) { + return Color.rgb( + (int) (Color.red(colour) + (255 - Color.red(colour)) * 0.45f), + (int) (Color.green(colour) + (255 - Color.green(colour)) * 0.45f), + (int) (Color.blue(colour) + (255 - Color.blue(colour)) * 0.45f)); + } + + /** + * The spread a lit phosphor has around its stroke. + * + *

Wide and weak on purpose: a narrow strong bloom lands on the stroke and + * thickens the letter, a wide weak one lands around it and does not. 6dp at + * half alpha is the reference sheet's own text-shadow. + */ + public void bloom(TextView text, int colour) { + if (!skin.crt) return; + text.setShadowLayer(dp(6), 0f, 0f, Color.argb(128, Color.red(colour), + Color.green(colour), Color.blue(colour))); + } + + /** + * Make a line of phosphor brighten under the ray instead of gaining a + * background. The text keeps its place, its weight and its colour family — only + * the beam's intensity changes, which is the one thing a tube can actually do. + */ + public void glowOnHover(final View target, final TextView text, final int resting) { + final int lit = brighten(resting); + target.setOnHoverListener((v, event) -> { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_HOVER_ENTER: + text.setTextColor(lit); + text.setShadowLayer(dp(10), 0f, 0f, Color.argb(170, Color.red(lit), + Color.green(lit), Color.blue(lit))); + break; + case MotionEvent.ACTION_HOVER_EXIT: + text.setTextColor(resting); + bloom(text, resting); + break; + default: + break; + } + return false; + }); + } + + /** + * A lit key writes its legend in its own edge colour. + * + *

On a moulded cap the fill already carries the role and the legend stays + * white; on an outlined one the fill is nearly the deck, so the colour has + * nowhere else to live. It is the same information either way. + */ + private static int labelColour(int style) { + if (physicalSkin()) return skin.legend[style]; + return skin.border > 0 && lit(style) ? skin.edge[style] : TEXT; + } + /** * The shallow shadow that makes a legend look cut into the cap rather than * printed on it. @@ -208,10 +883,17 @@ public TextView glyphKey(Glyphs.Kind kind, int style, String hint, View.OnClickL private void centre(TextView view, Glyphs.Kind kind) { view.setCompoundDrawables(null, null, null, null); Drawable glyph = Glyphs.drawable(context, kind, TEXT, iconSizeDp()); - LayerDrawable stack = new LayerDrawable(new Drawable[]{view.getBackground(), glyph}); - stack.setLayerGravity(1, Gravity.CENTER); - stack.setLayerSize(1, glyph.getIntrinsicWidth(), glyph.getIntrinsicHeight()); - view.setBackground(stack); + // Layered into the background, and also remembered. + // + // Both halves of that matter and both were learned the hard way. A + // compound drawable is laid out beside the text and centred with it, so on + // a key with no text it sits visibly left of centre; a foreground would be + // ideal and is simply not drawn on these views. That leaves a layer of the + // background — which works until something replaces that background, as + // the rocker halves do, and the glyph goes with it. So the glyph is kept + // here as well, and whoever swaps a background can put it back. + glyphs.put(view, glyph); + view.setBackground(stack(view.getBackground(), glyph)); } /** @@ -230,6 +912,28 @@ public LinearLayout rocker(View top, View bottom) { cap.setBackground(rocked()); cap.setClipToOutline(true); + if (physicalSkin()) { + // The pair is one pitched cap sitting in a recessed housing, so the + // halves are shaded as slopes and the housing is what they sit in. + // Nothing else here changes: the two views, the groove and the order + // are the same on every skin. + // The same housing colour and the same concentric radius as every + // other recessed region. It used to be near-black with a corner of its + // own, which read as a part borrowed from a different panel — the only + // hard black outline in a bar that has none anywhere else. + cap.setBackground(new Section(0xFF0D141E, dp(containerCornerDp()))); + cap.setPadding(dp(3), dp(3), dp(3), dp(3)); + mountSlope(top, true); + mountSlope(bottom, false); + cap.addView(top, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + cap.addView(bottom, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + return cap; + } + cap.addView(top, new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); @@ -258,11 +962,26 @@ public LinearLayout rocker(View top, View bottom) { * gradient is a description of the object rather than decoration on it, and it * says which way each half will go before it is pressed. */ - private GradientDrawable rocked() { + private Drawable rocked() { + if (skin.kind == Skin.Kind.MACHINED) { + // The cap of a real rocker is one moulding, so it is one KeyFace; the + // groove drawn across it by rocker() is what makes it two controls. + return new KeyFace(FILL[KEY], dp(skin.cornerDp), dp(skin.depthDp), false, + skin.textured); + } GradientDrawable shape = new GradientDrawable(); + shape.setCornerRadius(dp(skin.cornerDp)); + if (skin.border > 0) { + // No lighting to describe: this cap is a drawn region like every other + // key in this skin, so it takes the same flat face and the same edge. + // What still says "rocker" is the groove, which is a fact about the + // control rather than about how light falls on it. + shape.setColor(FILL[KEY]); + shape.setStroke(dp(skin.border), skin.edge[KEY]); + return shape; + } shape.setOrientation(GradientDrawable.Orientation.TOP_BOTTOM); shape.setColors(new int[]{shade(FILL[KEY], 16), shade(FILL[KEY], -10), shade(FILL[KEY], 16)}); - shape.setCornerRadius(dp(8)); return shape; } @@ -274,6 +993,56 @@ private static int shade(int colour, int step) { Math.max(0, Math.min(255, Color.blue(colour) + step))); } + /** + * Puts a slope under a half without losing the glyph drawn on it. + * + *

The glyph comes from {@link #glyphs} rather than off the view, because + * there is nowhere on the view it survives: a foreground is not drawn on these + * views at all — trying that emptied every arrow and the backspace key on the + * keypad — and the background is the thing being replaced here. + */ + private void mountSlope(View half, boolean upper) { + Drawable glyph = glyphs.get(half); + if (glyph == null) { + half.setBackground(slope(upper)); + return; + } + half.setBackground(slopeWithGlyph(upper, glyph)); + } + + private StateListDrawable slopeWithGlyph(boolean upper, Drawable glyph) { + StateListDrawable states = new StateListDrawable(); + int fill = FILL[KEY]; + float r = dp(skin.cornerDp); + states.addState(new int[]{android.R.attr.state_pressed}, + stack(new RockerHalf(upper, fill, r, true), glyph)); + states.addState(new int[]{android.R.attr.state_hovered}, + stack(new RockerHalf(upper, lighten(fill, 0.08f), r, false), glyph)); + states.addState(new int[]{}, stack(new RockerHalf(upper, fill, r, false), glyph)); + return states; + } + + /** A face with a glyph centred over it, taking no part in layout. */ + private LayerDrawable stack(Drawable face, Drawable glyph) { + LayerDrawable layers = new LayerDrawable(new Drawable[]{face, glyph}); + layers.setLayerGravity(1, Gravity.CENTER); + layers.setLayerSize(1, glyph.getIntrinsicWidth(), glyph.getIntrinsicHeight()); + return layers; + } + + /** One slope of a pitched rocker, in its three states. */ + private StateListDrawable slope(boolean upper) { + StateListDrawable states = new StateListDrawable(); + int fill = FILL[KEY]; + float r = dp(skin.cornerDp); + states.addState(new int[]{android.R.attr.state_pressed}, + new RockerHalf(upper, fill, r, true)); + states.addState(new int[]{android.R.attr.state_hovered}, + new RockerHalf(upper, lighten(fill, 0.08f), r, false)); + states.addState(new int[]{}, new RockerHalf(upper, fill, r, false)); + return states; + } + /** * One half of a {@link #rocker}: a key with no cap of its own, so the cap's * corners are the only ones in play. @@ -281,6 +1050,12 @@ private static int shade(int colour, int step) { public TextView half(String label, String hint, View.OnClickListener onClick) { TextView view = key(label, KEY, hint, onClick); view.setBackground(pressOnly()); + // And undo the extrusion compensation `key` just applied. A half has no + // extrusion — it is a face on a cap that belongs to the pair — so the + // padding that centres a legend on a standing key pushes this one off + // centre instead. Visible on A+ / A− before it was noticed anywhere else. + view.setPadding(dp(padH), dp(padV), dp(padH), dp(padV)); + view.setMinHeight(dp(minHeight)); return view; } @@ -294,7 +1069,13 @@ public TextView glyphHalf(Glyphs.Kind kind, String hint, View.OnClickListener on private StateListDrawable pressOnly() { StateListDrawable states = new StateListDrawable(); GradientDrawable pressed = new GradientDrawable(); - pressed.setColor(Color.argb(70, 255, 255, 255)); + // The accent rather than white where there is an accent: a white wash on a + // near-black deck reads as a different material lighting up, which is the + // one thing a half of a rocker is not. + pressed.setColor(skin.border > 0 + ? Color.argb(64, Color.red(skin.accent), Color.green(skin.accent), + Color.blue(skin.accent)) + : Color.argb(70, 255, 255, 255)); GradientDrawable idle = new GradientDrawable(); idle.setColor(Color.TRANSPARENT); states.addState(new int[]{android.R.attr.state_pressed}, pressed); @@ -332,7 +1113,7 @@ public LinearLayout tab(String title, boolean active, int contentColour, int str label.setText(title); label.setTextColor(active ? TEXT : MUTED); label.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textDp); - label.setTypeface(Typeface.MONOSPACE); + label.setTypeface(Fonts.mono(context)); label.setSingleLine(true); label.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE); label.setMaxWidth(dp(220)); @@ -369,7 +1150,7 @@ private StateListDrawable tabBackground(int fill, boolean active) { private GradientDrawable tabShape(int colour) { GradientDrawable shape = new GradientDrawable(); shape.setColor(colour); - float r = dp(10); + float r = dp(5); shape.setCornerRadii(new float[]{r, r, r, r, 0, 0, 0, 0}); return shape; } @@ -378,7 +1159,12 @@ private GradientDrawable tabShape(int colour) { * Pressed and hovered states. With no touch and no mouse, the highlight under the ray * is the only thing that says where a press would land before it lands there. */ - private StateListDrawable background(int fill) { + private StateListDrawable background(int style) { + if (physicalSkin()) return physical(style); + if (skin.kind == Skin.Kind.MACHINED) return moulded(style); + if (skin.border > 0) return outlined(style); + + int fill = FILL[style]; StateListDrawable states = new StateListDrawable(); // Pressed is the one state with no lip: the cap sits down flush with the // deck, which is what pressing a key does and costs nothing to draw. @@ -388,6 +1174,133 @@ private StateListDrawable background(int fill) { return states; } + /** + * The full physical recipe, per state. {@link PhysicalKey} holds the layers; + * this decides what each state changes. + * + *

Hover brightens the border and lights a halo, and does not move the cap. + * Press moves it, and that movement is the whole feedback — a hover that also + * moved would leave the press with nothing left to say. + */ + private StateListDrawable physical(int style) { + StateListDrawable states = new StateListDrawable(); + float radius = dp(skin.cornerDp); + float depth = dp(skin.depthDp); + int face = FILL[style]; + int border = skin.edge[style]; + int legend = skin.legend[style]; + + // Primary actions stay illuminated at rest. Everything else lights only + // under the ray, so a bar sitting untouched has one lit control, not forty. + int resting = style == ENTER ? skin.accentGo : 0; + + states.addState(new int[]{android.R.attr.state_pressed}, + new PhysicalKey(face, border, legend, radius, depth, true, resting)); + states.addState(new int[]{android.R.attr.state_hovered}, + new PhysicalKey(lighten(face, 0.06f), blend(border, skin.accentNav, 0.6f), + legend, radius, depth, false, skin.accentNav)); + states.addState(new int[]{}, + new PhysicalKey(face, border, legend, radius, depth, false, resting)); + return states; + } + + /** Part-way between two colours, for a border that warms up under the ray. */ + private static int blend(int from, int to, float amount) { + return Color.argb( + (int) (Color.alpha(from) + (Color.alpha(to) - Color.alpha(from)) * amount), + (int) (Color.red(from) + (Color.red(to) - Color.red(from)) * amount), + (int) (Color.green(from) + (Color.green(to) - Color.green(from)) * amount), + (int) (Color.blue(from) + (Color.blue(to) - Color.blue(from)) * amount)); + } + + /** + * A moulded cap: {@link KeyFace} does the drawing, this only says which of + * its three states is which. + * + *

Hover lightens the face and leaves the cap standing. Press puts it down — + * a different drawable rather than a lighter copy of the same one, because + * what changes is where the light lands, not how much of it there is. + */ + private StateListDrawable moulded(int style) { + StateListDrawable states = new StateListDrawable(); + float radius = dp(skin.cornerDp); + float depth = dp(skin.depthDp); + states.addState(new int[]{android.R.attr.state_pressed}, + new KeyFace(FILL[style], radius, depth, true, skin.textured)); + states.addState(new int[]{android.R.attr.state_hovered}, + new KeyFace(lighten(FILL[style], 0.14f), radius, depth, false, skin.textured)); + states.addState(new int[]{}, + new KeyFace(FILL[style], radius, depth, false, skin.textured)); + return states; + } + + /** + * A key with no volume at all: a face barely above the deck and a lit edge. + * + *

Press and hover are shown by the edge and the face brightening together + * rather than by the key moving. There is nowhere for it to move to — the + * whole point of this treatment is that the key is a drawn region, not an + * object, so a press that mimicked depth would be describing something that + * is not there. + */ + private StateListDrawable outlined(int style) { + StateListDrawable states = new StateListDrawable(); + states.addState(new int[]{android.R.attr.state_pressed}, + face(lighten(FILL[style], 0.22f), skin.edge[style], 1.0f)); + states.addState(new int[]{android.R.attr.state_hovered}, + face(lighten(FILL[style], 0.10f), skin.edge[style], 0.7f)); + states.addState(new int[]{}, face(FILL[style], skin.edge[style], lit(style) ? 0.42f : 0f)); + return states; + } + + /** + * Which roles are drawn as lit rather than merely outlined. + * + *

Everything lit either runs on its own or is destructive. A bar where + * every key glows is a bar where the glow means nothing, and the keys that + * most need to be told apart from their neighbours are exactly these. + */ + private static boolean lit(int style) { + return style == COMMAND || style == ENTER || style == WARN || style == VOICE; + } + + /** + * One face of an outlined key: fill, then rings of falling alpha just inside + * the edge, then the edge itself. + * + *

The rings are three concentric strokes rather than a blur. A real blur + * means {@link android.graphics.BlurMaskFilter}, which only works on a + * software layer, and the left half of this bar is rebuilt whenever the + * foreground process changes; at this size three strokes read the same and + * cost nothing. + */ + private LayerDrawable face(int fill, int edgeColour, float glowStrength) { + GradientDrawable body = new GradientDrawable(); + body.setColor(fill); + body.setCornerRadius(dp(skin.cornerDp)); + + if (!skin.glow || glowStrength <= 0f) { + body.setStroke(dp(skin.border), edgeColour); + return new LayerDrawable(new Drawable[]{body}); + } + + // Widest and faintest first: each later ring is drawn over the previous + // one and reaches less far in, so the alpha climbs towards the edge. + GradientDrawable wide = ring(edgeColour, skin.border + 3, (int) (34 * glowStrength)); + GradientDrawable mid = ring(edgeColour, skin.border + 1, (int) (72 * glowStrength)); + GradientDrawable line = ring(edgeColour, skin.border, 255); + return new LayerDrawable(new Drawable[]{body, wide, mid, line}); + } + + private GradientDrawable ring(int colour, int widthDp, int alpha) { + GradientDrawable shape = new GradientDrawable(); + shape.setColor(Color.TRANSPARENT); + shape.setCornerRadius(dp(skin.cornerDp)); + shape.setStroke(dp(widthDp), Color.argb(Math.min(255, alpha), + Color.red(colour), Color.green(colour), Color.blue(colour))); + return shape; + } + /** * A key with a lip: the cap, and under it a darker copy showing along the * bottom edge. @@ -401,14 +1314,14 @@ private StateListDrawable background(int fill) { private LayerDrawable capped(int fill) { GradientDrawable lip = new GradientDrawable(); lip.setColor(shade(fill, -26)); - lip.setCornerRadius(dp(8)); + lip.setCornerRadius(dp(skin.cornerDp)); // The face is flat. The volume comes from the lip below it and nothing // else: a gradient across the cap was doing a second job the lip already // does, and two cues for one fact read as a style rather than as a shape. GradientDrawable cap = new GradientDrawable(); cap.setColor(fill); - cap.setCornerRadius(dp(8)); + cap.setCornerRadius(dp(skin.cornerDp)); LayerDrawable stack = new LayerDrawable(new Drawable[]{lip, cap}); // One device pixel, written as 1 rather than dp(1): on a dense panel a dp @@ -421,7 +1334,7 @@ private LayerDrawable capped(int fill) { private GradientDrawable solid(int colour) { GradientDrawable shape = new GradientDrawable(); shape.setColor(colour); - shape.setCornerRadius(dp(8)); + shape.setCornerRadius(dp(skin.cornerDp)); return shape; } diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/ContextBar.java b/client/app/src/main/java/dev/butschster/linuxterminal/ContextBar.java index 0896723..8495582 100644 --- a/client/app/src/main/java/dev/butschster/linuxterminal/ContextBar.java +++ b/client/app/src/main/java/dev/butschster/linuxterminal/ContextBar.java @@ -1,13 +1,14 @@ package dev.butschster.linuxterminal; +import android.app.Activity; import android.content.Context; -import android.graphics.Color; import android.graphics.Typeface; import android.text.TextUtils; import android.util.TypedValue; import android.view.Gravity; import android.view.View; import android.widget.LinearLayout; +import android.widget.PopupWindow; import android.widget.ScrollView; import android.widget.TextView; @@ -54,11 +55,15 @@ public interface Host { /** The app's own strip. One button wide: it is a margin, not a third of the bar. */ private static final int STRIP_WIDTH_DP = 64; - private static final int BG = Color.rgb(24, 25, 31); - private static final int BG_FIXED = Color.rgb(31, 33, 40); + // The two plates come from the skin at build time rather than from constants + // here: the whole point of a skin is that a plate changes together with the + // keys standing on it, and a deck that stayed grey under lit keys would + // compare nothing. private static final String ICON_MIC = "\ue029"; private static final String ICON_STOP = "\ue047"; + /** Material's "palette": the only thing on the bar that changes the bar. */ + private static final String ICON_APPEARANCE = "\ue40a"; // The keyboard mark is shared with the terminal window's button, so it lives in // Buttons rather than here \u2014 see the note there. @@ -81,6 +86,18 @@ public interface Host { private final FlowLayout toolKeys; private View divider; + /** Non-null only on a skin with displays: the column the screens are stacked in. */ + private LinearLayout stack; + + /** Console layout only: the listing, the chip column and the two mini screens. */ + private FlowLayout rows; + private FlowLayout projects; + private View browserBox; + private View projectBox; + private View commandBox; + private LinearLayout chipColumn; + private LinearLayout shortcuts; + private LinearLayout left; private LinearLayout right; private LinearLayout recordingRow; @@ -95,11 +112,36 @@ public interface Host { public ContextBar(Context context, Host host) { super(context); this.host = host; + // Before the first drawable is asked for: every background on this panel is + // built from the skin as the view is constructed, so a skin restored one + // line later would apply to nothing that has already been made. + Appearance.restore(context); Typeface icons = Typeface.createFromAsset(context.getAssets(), "MaterialIcons-Regular.ttf"); this.buttons = new Buttons(context, icons); setOrientation(HORIZONTAL); - setBackgroundColor(BG); + setBackground(Buttons.deck(false)); + + // The four views every layout needs, built before either branch claims + // them: the two lines of the status, the flow of tool keys, and the flow + // the older skins put their groups in. Which container they end up inside + // is what the layouts disagree about; that they exist is not. + where = label(path, Buttons.TEXT, 16); + where.setTypeface(Fonts.mono(context)); + where.setEllipsize(TextUtils.TruncateAt.MIDDLE); + where.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + + what = label("", Buttons.MUTED, 15); + what.setTypeface(Fonts.mono(context)); + what.setPadding(buttons.dp(10), 0, buttons.dp(8), 0); + + dynamic = new FlowLayout(context, buttons.gap()); + toolKeys = new FlowLayout(context, buttons.gap()); + + if (Buttons.skin().kind == Skin.Kind.CONSOLE) { + buildConsole(context); + return; + } // ---------------------------------------------------------- left: dynamic @@ -111,25 +153,38 @@ public ContextBar(Context context, Host host) { left.setPadding(0, buttons.dp(8), 0, buttons.dp(8)); addView(left, new LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)); + boolean screens = Buttons.skin().displays; + LinearLayout status = new LinearLayout(context); status.setOrientation(HORIZONTAL); status.setGravity(Gravity.CENTER_VERTICAL); status.setPadding(buttons.dp(10), 0, buttons.dp(6), 0); - left.addView(status, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + if (screens) { + // The first display, and the one that is only ever read: where you are. + // It was already text-only — nothing on this line has ever been + // pressable — so putting it behind glass says out loud what the layout + // was already doing quietly. + status.setBackground(buttons.display()); + status.setPadding(buttons.dp(14), buttons.dp(9), buttons.dp(14), buttons.dp(9)); + LayoutParams inset = new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + inset.setMargins(buttons.inset(), 0, buttons.inset(), buttons.dp(8)); + left.addView(status, inset); + } else { + left.addView(status, + new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } // Text only — nothing here is pressable. A status line that can be clicked // is a row of accidental targets above the row you were aiming at. - where = label(path, Buttons.TEXT, 16); - where.setTypeface(Typeface.MONOSPACE); - where.setEllipsize(TextUtils.TruncateAt.MIDDLE); - where.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + where.setTextColor(screens ? Buttons.skin().phosphor : Buttons.TEXT); status.addView(where, new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); - what = label("", Buttons.MUTED, 15); - what.setPadding(buttons.dp(10), 0, 0, 0); + // Dimmer phosphor rather than grey: a second colour inside one display + // would read as a second display. Same lamp, less of it. + what.setTextColor(screens ? dim(Buttons.skin().phosphor, 0.62f) : Buttons.MUTED); status.addView(what); - dynamic = new FlowLayout(context, buttons.gap()); dynamic.setPadding(buttons.inset(), buttons.dp(2), buttons.inset(), 0); // The rules are drawn past this padding, out to the panel's own edges. // Both flags are needed and they are not the same thing: clipToPadding @@ -139,8 +194,19 @@ public ContextBar(Context context, Host host) { dynamic.setClipToPadding(false); dynamic.setClipChildren(false); ScrollView dynamicScroll = new ScrollView(context); - dynamicScroll.addView(dynamic, new ScrollView.LayoutParams( - LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + if (screens) { + // One display per group, stacked. The flow layout is still built and + // still holds the buttons — it just moves inside a screen instead of + // sitting on the plate, so setContext keeps one way of filling it. + stack = new LinearLayout(context); + stack.setOrientation(VERTICAL); + stack.setPadding(buttons.inset(), 0, buttons.inset(), 0); + dynamicScroll.addView(stack, new ScrollView.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } else { + dynamicScroll.addView(dynamic, new ScrollView.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } left.addView(dynamicScroll, new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)); // ---------------------------------------------------------- right: fixed @@ -151,7 +217,7 @@ public ContextBar(Context context, Host host) { right = new LinearLayout(context); right.setOrientation(VERTICAL); - right.setBackgroundColor(BG_FIXED); + right.setBackground(Buttons.deck(true)); // No horizontal padding on the panel, for the same reason the left one has // none: a rule that stops short of the edge looks like a mistake. What // needs the inset takes it individually, and then everything below a rule @@ -181,7 +247,6 @@ public ContextBar(Context context, Host host) { new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); // The same inset the keys above it have, so the two blocks share an edge. - toolKeys = new FlowLayout(context, buttons.gap()); // The same gap below the rule as above it. Two above and eight below is // not a rhythm, it is a rule that has slid into the row underneath. // Its children carry no margins of their own, so it takes the inset whole. @@ -210,13 +275,18 @@ public ContextBar(Context context, Host host) { LinearLayout controls = new LinearLayout(context); controls.setOrientation(VERTICAL); - controls.setBackgroundColor(BG_FIXED); + controls.setBackground(Buttons.deck(true)); controls.setPadding(buttons.dp(6), buttons.dp(8), buttons.dp(6), buttons.dp(8)); addView(controls, new LayoutParams(buttons.dp(STRIP_WIDTH_DP), LayoutParams.MATCH_PARENT)); - addControl(controls, buttons.icon(ICON_MIC, Buttons.VOICE, "dictate", v -> host.onDictate())); - addControl(controls, buttons.icon(ICON_KEYBOARD, Buttons.COMMAND, "on-screen keyboard", - v -> host.onKeyboard())); + // Dictation and the keyboard as one block on every skin, not only on the + // console one. They answer the same question — how does text get into the + // line — and a cluster says so without implying, as a rocker would, that + // one is a direction of the other. + addControl(controls, buttons.cluster(true, + buttons.icon(ICON_MIC, Buttons.VOICE, "dictate", v -> host.onDictate()), + buttons.icon(ICON_KEYBOARD, Buttons.COMMAND, "on-screen keyboard", + v -> host.onKeyboard()))); // Up over down, everywhere on this strip: in every rocker here the upper // half is the one that moves away from where you are. // @@ -245,6 +315,8 @@ public ContextBar(Context context, Host host) { buttons.half("A+", "larger text", v -> host.onFontStep(2)), buttons.half("A\u2212", "smaller text", v -> host.onFontStep(-2)))); + addControl(controls, appearanceKey()); + // Descriptions ride on the buttons themselves. A hint printed somewhere // else — a line under the keys, the path at the top of another panel — is a // hint in the one place the eye is not while it is aiming. @@ -274,7 +346,7 @@ public ContextBar(Context context, Host host) { new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); timer = label("0:00", Buttons.TEXT, 22); - timer.setTypeface(Typeface.MONOSPACE); + timer.setTypeface(Fonts.mono(context)); timer.setPadding(0, 0, buttons.dp(20), 0); under.addView(timer); @@ -303,6 +375,97 @@ private View pageKey(Glyphs.Kind kind, String bytes, String hint) { return view; } + /** + * The one control on this panel that is about the panel. + * + *

It sits on the strip with the other things that are the client's own and + * are never sent to the shell, and it is on that strip in both + * arrangements — the console rail and the older skins' controls. A way out of a + * skin has to exist inside every skin, or choosing one of them once is choosing + * it forever. + */ + private View appearanceKey() { + return buttons.icon(ICON_APPEARANCE, Buttons.DESTINATION, + "how this panel looks", this::showAppearance); + } + + /** + * The skins, offered where the person wearing the headset is. + * + *

Drawn from this panel's own parts rather than as a {@code PopupMenu}: the + * system menu is a light rectangle with a system typeface, and at half a metre + * it arrives as a piece of another application landing on top of this one. + * + *

Placed at the centre of the bar rather than under the button that opened + * it. The strip it is opened from is a few keys wide and at the edge of the + * window, so a dropdown either overflows the window or is squeezed into the + * strip's width; the panel's middle is somewhere the ray is already pointing. + */ + private void showAppearance(View anchor) { + Skin skin = Buttons.skin(); + + LinearLayout menu = new LinearLayout(getContext()); + menu.setOrientation(VERTICAL); + menu.setBackground(buttons.section(true)); + int pad = buttons.dp(12); + menu.setPadding(pad, pad, pad, pad); + + TextView caption = label("APPEARANCE", skin.heading, 12); + caption.setLetterSpacing(0.10f); + caption.setPadding(buttons.dp(8), 0, 0, buttons.dp(8)); + menu.addView(caption); + + PopupWindow popup = new PopupWindow(menu, buttons.dp(320), + LayoutParams.WRAP_CONTENT, true); + // Dismissed by pressing away from it: a menu with no way out but a choice + // is a menu that cannot be opened out of curiosity. + popup.setOutsideTouchable(true); + + String current = Appearance.chosen(getContext()); + for (Appearance.Choice choice : Appearance.CHOICES) { + menu.addView(appearanceRow(choice, choice.id.equals(current), popup), + new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } + + popup.showAtLocation(this, Gravity.CENTER, 0, 0); + } + + private View appearanceRow(Appearance.Choice choice, boolean active, PopupWindow popup) { + Skin skin = Buttons.skin(); + + LinearLayout row = new LinearLayout(getContext()); + row.setOrientation(VERTICAL); + row.setPadding(buttons.dp(8), buttons.dp(8), buttons.dp(8), buttons.dp(8)); + row.setClickable(true); + row.setBackground(buttons.rowSelection()); + + // The mark, not a tick in a second column: the listing on this panel already + // says "this one" with a caret in front of the name, and one language for + // "you are here" is worth more than a prettier tick. + TextView name = label((active ? "▸ " : " ") + choice.name, + active ? skin.accentGo : Buttons.TEXT, 16); + name.setTypeface(Fonts.mono(getContext())); + row.addView(name); + + TextView note = label(" " + choice.note, Buttons.MUTED, 12); + note.setTypeface(Fonts.mono(getContext())); + row.addView(note); + + row.setOnClickListener(v -> { + popup.dismiss(); + if (active) return; + Appearance.choose(getContext(), choice); + // The whole panel is built from the skin as its views are constructed, + // so there is nothing to repaint — the window is made again instead. + // A terminal window already up keeps the look it was built with until + // it is next opened, which is the honest consequence of building the + // look once rather than watching for it. + Context context = getContext(); + if (context instanceof Activity) ((Activity) context).recreate(); + }); + return row; + } + private void addControl(LinearLayout strip, View view) { LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); int half = buttons.gap() / 2; @@ -363,6 +526,17 @@ public void setContext(JSONObject ctx) { } } hint(toolKeys, null); + + if (rows != null) { + fillConsole(groups); + return; + } + + if (stack != null) { + buildScreens(groups); + return; + } + for (int i = 0; i < groups.length(); i++) { JSONObject group = groups.optJSONObject(i); if (group == null) continue; @@ -382,7 +556,7 @@ public void setContext(JSONObject ctx) { // for what follows rather than a line competing with the buttons. The // rule carries the separation, so the heading does not have to. TextView heading = label(group.optString("name").toUpperCase(Locale.ROOT), - Buttons.MUTED, 9); + Buttons.skin().heading, 9); heading.setPadding(0, buttons.dp(1), buttons.dp(10), buttons.dp(1)); heading.setLetterSpacing(0.12f); heading.setTag(FlowLayout.BREAK); @@ -408,6 +582,627 @@ public void setContext(JSONObject ctx) { hint(dynamic, where); } + // --------------------------------------------------------------- console + + /** + * The console arrangement, built from this bar's own parts. + * + *

The layout is borrowed; the contents are not. The keypad here is the real + * {@link KeyPad} — the same keys, built once, that never move — and the + * shortcuts are whatever the host sent for the program in front, not a fixed + * list of Claude's chords. A concept that hardcoded either would prove nothing + * about this bar: the whole design rests on the host deciding what is offered, + * and a mock that decides for itself is a different product. + * + *

What the arrangement changes is where a thing lives, and the rule is what + * kind of thing it is. Read on the left, pressed in the middle, and the app's + * own controls on a rail at the edge. + */ + private void buildConsole(Context context) { + setPadding(buttons.dp(12), buttons.dp(12), buttons.dp(12), buttons.dp(12)); + + // ---- read + LinearLayout reading = new LinearLayout(context); + reading.setOrientation(VERTICAL); + addView(reading, new LayoutParams(0, LayoutParams.MATCH_PARENT, 1.35f)); + + // The head is its own screen, the full width of the column and with no + // section around it. It answers a different question from the listing — + // "where am I" against "where could I go" — and wrapping the two in one + // housing said they were one instrument. Nothing else on the panel is + // unwrapped, which is what makes this one read as the panel's own readout. + LinearLayout head = new LinearLayout(context); + head.setOrientation(HORIZONTAL); + head.setGravity(Gravity.CENTER_VERTICAL); + head.setBackground(buttons.display()); + head.setPadding(buttons.dp(14), buttons.dp(10), buttons.dp(14), buttons.dp(10)); + // The header is a screen, so it is written by the same beam as the screens + // under it. It was inheriting the panel's neutral text and dim grey from + // the constructor, which put two colours behind one piece of glass — the + // exact fault fixed once already for the typeface, arriving again through + // the palette. + where.setTextColor(Buttons.skin().phosphor); + what.setTextColor(Buttons.skin().phosphorDim); + // And the same bloom. The header was the one lit surface drawing its text + // flat: the colour had been fixed, the beam had not, so it read as green + // ink rather than as a lit line. + buttons.bloom(where, Buttons.skin().phosphor); + buttons.bloom(what, Buttons.skin().phosphorDim); + head.addView(where, new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)); + head.addView(what); + head.addView(new Led(context, Buttons.skin().accentGo, true), + new LayoutParams(buttons.dp(9), buttons.dp(9))); + LayoutParams headParams = new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + headParams.bottomMargin = buttons.dp(12); + reading.addView(head, headParams); + + // No plate around the listings. The glass is already a housing, and a box + // around a box at nearly the same radius reads as a framing error — two + // walls 10dp apart, the outer one only 2% brighter than the panel, which + // through the lenses is the same colour. The caption stays; it is engraved + // on the panel, which is where a label belongs. + LinearLayout browser = captioned(context, "directory browser", + consoleScreen(context)); + addElastic(reading, browser); + browserBox = browser; + + projects = new FlowLayout(context, 0); + projects.setPadding(buttons.dp(6), buttons.dp(6), buttons.dp(6), buttons.dp(6)); + projects.setClipChildren(false); + projects.setClipToPadding(false); + ScrollView projectScroll = elasticScroll(context); + projectScroll.setBackground(buttons.display()); + projectScroll.setClipChildren(false); + projectScroll.addView(projects, new ScrollView.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + LinearLayout projectBox = captioned(context, "projects", projectScroll); + addElastic(reading, projectBox); + this.projectBox = projectBox; + + // Last in the column and the last thing to give up room, which is the + // opposite of how it was built. + // + // The commands had the weight and the two listings above them did not, so + // the listings took what their contents asked for — up to 230dp each — and + // the commands were handed whatever was left. In a column 460dp tall there + // was nothing left: `claude`, `codex`, `git status` and the rest measured to + // zero height and simply were not on the panel. They had not been removed, + // they had been pushed off the bottom by two directories. + LinearLayout commandBox = captioned(context, "commands", null); + chipColumn = new LinearLayout(context); + chipColumn.setOrientation(VERTICAL); + ScrollView chipScroll = elasticScroll(context, 200); + chipScroll.addView(chipColumn, new ScrollView.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + commandBox.addView(chipScroll, new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + addSection(reading, commandBox, 0f); + this.commandBox = commandBox; + + addView(gap(context, 12), new LayoutParams(buttons.dp(12), LayoutParams.MATCH_PARENT)); + + // ---- press + // + // The keypad is OUTSIDE the scroller and the shortcuts are inside it, and + // that split is the whole point of this column. + // + // The first version put both in one ScrollView. The keypad is still built + // once and never rebuilt — the rule everyone checks — and it still moved: + // when the host sent enough shortcuts to overflow the column, scrolling + // down to reach a chord carried Enter and ^C up and off the panel. That is + // the Touch Bar's failure arriving through the scroll container instead of + // through the layout order, and the fixed half's guarantee is about where + // the keys ARE, not about how often they are constructed. + LinearLayout pressing = new LinearLayout(context); + pressing.setOrientation(VERTICAL); + addView(pressing, new LayoutParams(0, LayoutParams.MATCH_PARENT, 0.95f)); + + LinearLayout control = consoleSection(context, "terminal control", true); + KeyPad pad = new KeyPad(context, buttons, text -> host.onAction(text, false)); + control.addView(pad, new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + addSection(pressing, control, 0f); + + shortcuts = consoleSection(context, "shortcuts", false); + ScrollView shortcutScroll = new ScrollView(context); + shortcutScroll.addView(toolKeys, new ScrollView.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + shortcuts.addView(shortcutScroll, new LayoutParams( + LayoutParams.MATCH_PARENT, 0, 1f)); + LayoutParams shortcutParams = new LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f); + shortcutParams.bottomMargin = buttons.dp(12); + pressing.addView(shortcuts, shortcutParams); + + addView(gap(context, 12), new LayoutParams(buttons.dp(12), LayoutParams.MATCH_PARENT)); + + // ---- the app's own + addView(consoleRail(context), new LayoutParams( + buttons.dp(86), LayoutParams.MATCH_PARENT)); + } + + /** The screen: nothing but the listing, in columns. */ + private View consoleScreen(Context context) { + rows = new FlowLayout(context, 0); + rows.setPadding(buttons.dp(6), buttons.dp(6), buttons.dp(6), buttons.dp(6)); + // A bloom is drawn outside the letter, so every container between the text + // and the screen has to stop clipping — otherwise the glow ends in a hard + // vertical line at the cell's edge, which is what a lit row was doing here + // while the same effect looked right on the other skins, where the row is + // the full width of the screen and the clip falls outside the glow. + rows.setClipChildren(false); + rows.setClipToPadding(false); + ScrollView scroll = elasticScroll(context); + scroll.setBackground(buttons.display()); + scroll.setClipChildren(false); + scroll.addView(rows, new ScrollView.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + return scroll; + } + + /** + * A screen that is as tall as what is on it, up to a ceiling. + * + *

Two directories should not be given the same height as twenty. A weight + * would divide the column by a ratio decided when the panel was built, which is + * exactly the wrong time to decide it — how many entries a directory has is + * known only when the host says so, and it changes on every `cd`. + * + *

The ceiling matters as much as the elasticity: a home directory with sixty + * entries would otherwise push the commands and the readouts off the panel + * entirely. Past it the listing scrolls, which is the behaviour a listing + * should have had anyway. + */ + private ScrollView elasticScroll(Context context) { + return elasticScroll(context, 230); + } + + /** + * The same, with the ceiling stated. The commands take a lower one: a chip is + * shorter than a listing row, so 200dp is already five or six rows of them, and + * anything past that is a wall of buttons rather than a section of the panel. + */ + private ScrollView elasticScroll(Context context, int ceilingDp) { + return new ScrollView(context) { + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + super.onMeasure(widthSpec, MeasureSpec.makeMeasureSpec( + buttons.dp(ceilingDp), MeasureSpec.AT_MOST)); + } + }; + } + + /** + * The rail: everything that moves you through text, as rockers. + * + *

All three pairs live here rather than being split between an edge rail and + * a navigation block in the middle. Paging, scrolling and text size are one + * intention — move me through this, or change how much of it I see — and a + * rocker is what says two actions are the same decision taken twice. + */ + private View consoleRail(Context context) { + LinearLayout rail = new LinearLayout(context); + rail.setOrientation(VERTICAL); + rail.setBackground(buttons.section(true)); + rail.setPadding(buttons.dp(8), buttons.dp(10), buttons.dp(8), buttons.dp(10)); + + // Dictation and the keyboard are buttons, not levers. + // + // A lever states a position you set and leave; these two are momentary — + // you press to start talking, and you press to summon a keyboard that + // dismisses itself. Drawing them as switches promised a state they do not + // hold. The sticky-modifier lever is gone outright: nothing in the client + // implements it, and a control that latches nothing is a lie with a hinge. + // One block, because they are one question: how does text get into the + // line. Not a rocker — neither is a direction of the other, and pressing + // one must not suggest the pair tilts. A cluster says "these belong + // together" while leaving each cap its own face to be pressed. + TextView mic = buttons.icon(ICON_MIC, Buttons.VOICE, "dictate", v -> host.onDictate()); + mic.setTextSize(TypedValue.COMPLEX_UNIT_DIP, buttons.iconSizeDp() + 6); + TextView keyboard = buttons.icon(ICON_KEYBOARD, Buttons.DESTINATION, + "on-screen keyboard", v -> host.onKeyboard()); + keyboard.setTextSize(TypedValue.COMPLEX_UNIT_DIP, buttons.iconSizeDp() + 4); + + View input = buttons.cluster(true, mic, keyboard); + LayoutParams inputParams = new LayoutParams( + LayoutParams.MATCH_PARENT, buttons.dp(104)); + inputParams.bottomMargin = buttons.dp(10); + rail.addView(input, inputParams); + + View pageUp = pageKey(Glyphs.Kind.PAGE_UP, ESC + "[5~", "page up, inside the program"); + View pageDown = pageKey(Glyphs.Kind.PAGE_DOWN, ESC + "[6~", "page down, inside the program"); + rail.addView(railRocker(context, pageUp, pageDown)); + + View back = buttons.glyphHalf(Glyphs.Kind.UP, "scroll this window back", + v -> host.onScroll(-10)); + buttons.repeatOnHold(back, () -> host.onScroll(-10)); + View forward = buttons.glyphHalf(Glyphs.Kind.DOWN, "scroll this window forward", + v -> host.onScroll(10)); + buttons.repeatOnHold(forward, () -> host.onScroll(10)); + rail.addView(railRocker(context, back, forward)); + + rail.addView(railRocker(context, + buttons.half("A+", "larger text", v -> host.onFontStep(2)), + buttons.half("A\u2212", "smaller text", v -> host.onFontStep(-2)))); + + rail.addView(appearanceKey(), new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + + hint(rail, null); + return rail; + } + + /** A single control on the rail, at the height its job deserves. */ + private View railControl(View view, int heightDp) { + LayoutParams params = new LayoutParams( + LayoutParams.MATCH_PARENT, buttons.dp(heightDp)); + params.bottomMargin = buttons.dp(10); + view.setLayoutParams(params); + return view; + } + + private View railRocker(Context context, View top, View bottom) { + View rocker = buttons.rocker(top, bottom); + LayoutParams params = new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.bottomMargin = buttons.dp(10); + rocker.setLayoutParams(params); + return rocker; + } + + /** + * Add a section to a column, with the gap between sections. + * + *

It exists because the obvious way silently does not work: a section sets + * its own {@code bottomMargin} in the constructor, and then {@code addView} + * with fresh {@code LayoutParams} throws those away — the parameters passed at + * add time replace the view's own. Every section on the panel was flush + * against the next one for exactly that reason. Routing every add through here + * means the gap is applied where it survives. + */ + /** + * A section that is as tall as its contents, and is the one that gives way when + * the column is too short for everything on it. + * + *

WRAP_CONTENT with a weight, which is a combination that looks like + * a contradiction and is not: the weight applies to what is left over after + * every child has been measured, and when that leftover is negative — the column + * is over-full — it is taken back from the children that carry weight. So the + * listings stay content-driven while there is room, and are the ones squeezed + * when there is not. A section added with {@link #addSection} keeps its height + * either way. + */ + private void addElastic(LinearLayout column, View section) { + LayoutParams params = new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, 1f); + params.bottomMargin = buttons.dp(12); + column.addView(section, params); + } + + private void addSection(LinearLayout column, View section, float weight) { + LayoutParams params = weight > 0 + ? new LayoutParams(LayoutParams.MATCH_PARENT, 0, weight) + : new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.bottomMargin = buttons.dp(12); + column.addView(section, params); + } + + /** + * A caption engraved on the panel with its content beneath it, and no plate. + * + *

For anything that is already a housing of its own — a screen, a scroller + * full of chips. What a plate buys is the statement "these belong together", + * and a screen makes that statement by being a screen. + */ + private LinearLayout captioned(Context context, String caption, View content) { + LinearLayout box = new LinearLayout(context); + box.setOrientation(VERTICAL); + + TextView label = label(caption.toUpperCase(Locale.ROOT), Buttons.skin().heading, 12); + label.setLetterSpacing(0.10f); + label.setPadding(buttons.dp(2), buttons.dp(2), 0, buttons.dp(7)); + box.addView(label); + + if (content != null) { + box.addView(content, new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } + return box; + } + + private LinearLayout consoleSection(Context context, String caption, boolean fixed) { + LinearLayout box = new LinearLayout(context); + box.setOrientation(VERTICAL); + box.setBackground(buttons.section(fixed)); + box.setPadding(buttons.dp(10), buttons.dp(10), buttons.dp(10), buttons.dp(10)); + + // 12dp and half the tracking. At 9dp with 0.18 tracking these were the + // least readable text on a surface whose regions they exist to name — and + // wide tracking makes it worse, because it destroys word shape, which is + // the only cue left once a glyph is too small to read letter by letter. + TextView label = label(caption.toUpperCase(Locale.ROOT), Buttons.skin().heading, 12); + label.setLetterSpacing(0.10f); + label.setPadding(buttons.dp(2), buttons.dp(2), 0, buttons.dp(9)); + box.addView(label); + + return box; + } + + private View gap(Context context, int dp) { + View view = new View(context); + view.setLayoutParams(new LayoutParams(buttons.dp(dp), buttons.dp(1))); + return view; + } + + /** + * Fill the console layout: two listings and a column of chips. + * + *

The split between the two listings is by what the action is, not + * by which group it arrived in. A directory is a step down from where you are; + * a favourite is a jump to somewhere else entirely — different distances, + * different risk of hitting the wrong one, and the host already marks them + * apart as {@code dir} against {@code fav}. Reading that instead of the group + * name also means a host that renames a group does not silently move its + * contents onto the wrong screen. + */ + private void fillConsole(JSONArray groups) { + rows.removeAllViews(); + projects.removeAllViews(); + chipColumn.removeAllViews(); + + for (int i = 0; i < groups.length(); i++) { + JSONObject group = groups.optJSONObject(i); + if (group == null) continue; + JSONArray actions = group.optJSONArray("actions"); + if (actions == null || actions.length() == 0) continue; + + boolean places = true; + for (int j = 0; j < actions.length(); j++) { + JSONObject action = actions.optJSONObject(j); + if (action != null && !isKeystroke(action)) places &= isPlace(action); + } + + if (places) { + for (int j = 0; j < actions.length(); j++) { + JSONObject action = actions.optJSONObject(j); + if (action == null || isKeystroke(action)) continue; + boolean bookmark = "fav".equals(action.optString("style", "key")); + (bookmark ? projects : rows).addView(consoleRow(action)); + } + continue; + } + + TextView caption = label(group.optString("name").toUpperCase(Locale.ROOT), + Buttons.skin().heading, 12); + caption.setLetterSpacing(0.10f); + caption.setPadding(buttons.dp(2), buttons.dp(6), 0, buttons.dp(6)); + chipColumn.addView(caption); + + FlowLayout flow = new FlowLayout(getContext(), buttons.gap()); + for (int j = 0; j < actions.length(); j++) { + JSONObject action = actions.optJSONObject(j); + if (action == null || isKeystroke(action)) continue; + String label = action.optString("label"); + String send = action.optString("send"); + boolean enter = action.optBoolean("enter"); + boolean danger = "warn".equals(action.optString("style", "key")); + View chip = buttons.chip(label, danger, v -> host.onAction(send, enter)); + String hint = action.isNull("hint") ? null : action.optString("hint"); + if (hint != null && !hint.isEmpty()) chip.setContentDescription(hint); + flow.addView(chip); + } + chipColumn.addView(flow, new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } + + // A section with nothing in it is not a section, it is a hole. + // + // With a program in front the host sends its keys and its skills and stops + // sending directories — there is nowhere to cd to from inside Claude Code — + // so the two reading screens would sit empty, taking the height the chips + // need. Hiding them gives that height back, and it happens by itself + // because it follows from what arrived rather than from knowing the name + // of the tool. + boolean anywhereToGo = rows.getChildCount() > 0; + boolean anyProjects = projects.getChildCount() > 0; + browserBox.setVisibility(anywhereToGo ? VISIBLE : GONE); + projectBox.setVisibility(anyProjects ? VISIBLE : GONE); + // And by the same rule the section that holds them: a caption over nothing + // is a section that failed to load, which is not what an empty one means. + commandBox.setVisibility(chipColumn.getChildCount() > 0 ? VISIBLE : GONE); + + hint(chipColumn, where); + } + + /** One line of the listing: a mark, the name, and what it is. */ + private View consoleRow(JSONObject action) { + String send = action.optString("send"); + boolean enter = action.optBoolean("enter"); + String style = action.optString("style", "key"); + boolean up = "up".equals(style); + String name = up ? ".." : action.optString("label"); + + // A cell, not a full-width line. `ls` prints a short listing in columns and + // so does this: one name per line filled the screen with six directories + // and pushed everything under it off the panel. The cell has a minimum + // width so the columns line up; a longer name takes what it needs and the + // next column starts later, which is again what `ls` does. + LinearLayout row = new LinearLayout(getContext()) { + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + super.onMeasure(widthSpec, heightSpec); + int min = buttons.dp(150); + if (getMeasuredWidth() < min) { + setMeasuredDimension(min, getMeasuredHeight()); + } + } + }; + row.setOrientation(HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(buttons.dp(8), buttons.dp(4), buttons.dp(8), buttons.dp(4)); + row.setClickable(true); + row.setClipChildren(false); + row.setClipToPadding(false); + row.setBackground(buttons.rowSelection()); + + TextView mark = label(up ? "\u2191" : "\u25b8", Buttons.skin().phosphorDim, 11); + mark.setTypeface(Fonts.mono(getContext())); + mark.setWidth(buttons.dp(16)); + row.addView(mark); + + TextView title = label(name, 0xFF39D863, 14); + title.setTypeface(Fonts.mono(getContext())); + title.setSingleLine(true); + title.setEllipsize(TextUtils.TruncateAt.MIDDLE); + // Room inside the view for the glow to land in. Clipping is off above, but + // a TextView still paints its shadow within its own bounds, so without the + // padding the spread has nowhere to go on the side the name ends at. + title.setPadding(0, buttons.dp(3), buttons.dp(8), buttons.dp(3)); + LayoutParams grow = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); + grow.setMarginStart(buttons.dp(8)); + row.addView(title, grow); + + // No "dir" / "proj" column any more. It was there when one listing held + // both kinds; with a screen each, the label repeated what the heading above + // it had already said — and in a narrow column it ended up jammed against + // the name it was describing. + + String hint = action.isNull("hint") ? null : action.optString("hint"); + if (hint != null && !hint.isEmpty()) row.setContentDescription(hint); + + // The listener goes on the row, not on the text. + // + // It was on the text and never fired: the row is what is clickable, so the + // row is what the ray hovers, and a TextView inside it that is neither + // clickable nor focusable is not asked about hover at all. Same effect, + // one level up — the row hears it and turns up the beam on its own label. + buttons.glowOnHover(row, title, 0xFF39D863); + row.setOnClickListener(v -> host.onAction(send, enter)); + return row; + } + + // ---------------------------------------------------------------- screens + + /** + * One display per group of places, and ordinary keys for everything else. + * + *

The split is by what the group is, not by which group it happens to be. + * A directory, a project, the parent — those are read and then chosen from, + * which is a list on a screen. `claude`, `git status`, `^R` are things that + * happen when pressed, and a thing that happens is a key. Putting the second + * kind behind glass would be the same mistake as making the first kind look + * pressable, in the other direction. + */ + private void buildScreens(JSONArray groups) { + stack.removeAllViews(); + for (int i = 0; i < groups.length(); i++) { + JSONObject group = groups.optJSONObject(i); + if (group == null) continue; + JSONArray actions = group.optJSONArray("actions"); + if (actions == null || actions.length() == 0) continue; + + java.util.List shown = new java.util.ArrayList<>(); + boolean allPlaces = true; + for (int j = 0; j < actions.length(); j++) { + JSONObject action = actions.optJSONObject(j); + if (action == null || isKeystroke(action)) continue; // drawn on the other side + shown.add(action); + allPlaces &= isPlace(action); + } + if (shown.isEmpty()) continue; + + // Engraved into the plate above the screen, not printed inside it. A + // label inside the glass would be one more lit thing to read past on + // the way to the list it names. + TextView heading = label(group.optString("name").toUpperCase(Locale.ROOT), + Buttons.MUTED, 9); + heading.setLetterSpacing(0.14f); + heading.setPadding(buttons.dp(3), buttons.dp(4), 0, buttons.dp(3)); + stack.addView(heading); + + boolean asListing = allPlaces && Buttons.skin().listing; + + android.view.ViewGroup items; + if (asListing) { + // Columns, the way `ls` prints them, not one name per line. A + // single column of six directories filled the whole panel and + // pushed the projects off the bottom — and it was not even what a + // terminal does with a short listing. + FlowLayout column = new FlowLayout(getContext(), 0); + for (JSONObject action : shown) { + column.addView(listingRow(action)); + } + items = column; + } else { + FlowLayout flow = new FlowLayout(getContext(), buttons.gap()); + for (JSONObject action : shown) { + flow.addView(allPlaces ? readout(action) : actionButton(action)); + } + items = flow; + } + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + params.bottomMargin = buttons.dp(8); + if (allPlaces) { + items.setBackground(buttons.display()); + int pad = asListing ? buttons.dp(6) : buttons.dp(10); + items.setPadding(buttons.dp(8), pad, buttons.dp(8), pad); + } else if (Buttons.skin().sections) { + // Keys are mounted in a milled region rather than standing on the + // bare panel, so a group of them reads as one block of controls. + items.setBackground(buttons.section()); + items.setPadding(buttons.dp(10), buttons.dp(10), + buttons.dp(10), buttons.dp(10)); + } + stack.addView(items, params); + } + hint(stack, where); + } + + /** One line of a listing: the cursor mark, then the name. */ + private View listingRow(JSONObject action) { + String send = action.optString("send"); + boolean enter = action.optBoolean("enter"); + String hint = action.isNull("hint") ? null : action.optString("hint"); + // The parent directory keeps its own mark. Everything else is a plain name + // with room in front of it for the cursor the highlight provides. + String label = "up".equals(action.optString("style", "key")) + ? ".." : action.optString("label"); + return buttons.row(label, hint, v -> host.onAction(send, enter)); + } + + /** A line on a screen: lit text, no cap, and the selection is an inversion. */ + private View readout(JSONObject action) { + String send = action.optString("send"); + boolean enter = action.optBoolean("enter"); + String hint = action.isNull("hint") ? null : action.optString("hint"); + View view = buttons.readout(action.optString("label"), hint, + v -> host.onAction(send, enter)); + + if ("up".equals(action.optString("style", "key")) && view instanceof TextView) { + ((TextView) view).setCompoundDrawablesWithIntrinsicBounds( + Glyphs.drawable(getContext(), Glyphs.Kind.UP, Buttons.skin().phosphor, + buttons.iconSizeDp()), null, null, null); + ((TextView) view).setCompoundDrawablePadding(buttons.dp(6)); + } + return view; + } + + /** Somewhere to go, as opposed to something to do. */ + private static boolean isPlace(JSONObject action) { + String style = action.optString("style", "key"); + return "up".equals(style) || "dir".equals(style) || "git".equals(style) + || "fav".equals(style); + } + + private static int dim(int colour, float amount) { + return android.graphics.Color.rgb( + (int) (android.graphics.Color.red(colour) * amount), + (int) (android.graphics.Color.green(colour) * amount), + (int) (android.graphics.Color.blue(colour) * amount)); + } + // -------------------------------------------------------------- dictation public void showRecording() { diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/Deck.java b/client/app/src/main/java/dev/butschster/linuxterminal/Deck.java new file mode 100644 index 0000000..2b7c5f8 --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/Deck.java @@ -0,0 +1,91 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +/** + * The plate the keys are mounted on. + * + *

Lit by the same source as {@link KeyFace} — above and in front — so the top + * of the plate is fractionally brighter than the bottom. The gradient is about + * 6%, far too little to notice as a gradient and just enough that the plate stops + * looking like a flat fill; a bar where the keys are lit and the plate is not + * reads as keys pasted onto a background. + * + *

The brushing runs horizontally, the length of the panel, which is how a + * plate this shape would actually be finished. + */ +public class Deck extends Drawable { + + private final int colour; + private final boolean textured; + + private final Paint base = new Paint(); + private final Paint texture = new Paint(); + private final Paint edge = new Paint(); + + public Deck(int colour, boolean textured) { + this.colour = colour; + this.textured = textured; + } + + @Override + protected void onBoundsChange(Rect bounds) { + base.setShader(new LinearGradient(0, bounds.top, 0, bounds.bottom, + shade(colour, 7), shade(colour, -5), Shader.TileMode.CLAMP)); + } + + @Override + public void draw(Canvas canvas) { + canvas.drawRect(getBounds(), base); + + // The housing's own edges: a hairline of light along the top of the panel + // and a darker one along the bottom. One pixel each — this is the outermost + // surface, and anything heavier here competes with the controls on it. + Rect bounds = getBounds(); + edge.setColor(0x0FFFFFFF); + canvas.drawRect(bounds.left, bounds.top, bounds.right, bounds.top + 1, edge); + edge.setColor(0xA6000000); + canvas.drawRect(bounds.left, bounds.bottom - 2, bounds.right, bounds.bottom, edge); + + if (!textured) return; + + // Fainter than on a key, deliberately. The plate is the largest surface in + // the window, so the same alpha that reads as a finish on a 46dp cap reads + // as dirt across 800dp of panel. + texture.setShader(Wear.grain()); + texture.setAlpha(13); + canvas.drawRect(getBounds(), texture); + + texture.setShader(Wear.use()); + texture.setAlpha(19); + canvas.drawRect(getBounds(), texture); + } + + private static int shade(int colour, int step) { + return Color.rgb( + Math.max(0, Math.min(255, Color.red(colour) + step)), + Math.max(0, Math.min(255, Color.green(colour) + step)), + Math.max(0, Math.min(255, Color.blue(colour) + step))); + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(ColorFilter filter) { + } + + @Override + public int getOpacity() { + return PixelFormat.OPAQUE; + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/Display.java b/client/app/src/main/java/dev/butschster/linuxterminal/Display.java new file mode 100644 index 0000000..e553274 --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/Display.java @@ -0,0 +1,224 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +/** + * A window cut into the panel: dark glass, set below the surface, with a phosphor + * lit behind it. + * + *

The opposite object to {@link KeyFace} and lit by the same lamp, which is the + * whole reason it reads as recessed rather than merely dark. A cap standing above + * the plate catches light on its top edge and casts a shadow below it; a well sunk + * into the plate is shadowed by its own upper wall and catches light on the lower + * one. Same light, inverted geometry — and that inversion is what the eye reads, + * not the darkness of the fill. + * + *

Why a display and not simply another group of keys: a key is a thing you press + * and a display is a thing you read, and the bar does both. Where you are, and what + * you could go to, are information — putting them behind glass says so before a + * word is read, and it takes them out of the population of things that look + * pressable. What stays a moulded cap is what is genuinely a key. + * + *

The glass carries a faint diagonal sheen across the top. It is the one purely + * decorative thing in this file and it earns its place by killing the flatness that + * a plain black rectangle has: a real cover glass reflects the room, and without any + * reflection at all the well reads as a hole rather than as a window. + */ +public class Display extends Drawable { + + private final int glass; + private final int depth; + private final Skin.Screen manner; + private final int edge; + private final boolean crt; + + /** A hairline around the glass, drawn over the wall. Zero means none. */ + private int bezel; + + private final Paint body = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint wall = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint sheen = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint scan = new Paint(); + private final Paint vignette = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint halo = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final RectF face = new RectF(); + private final float radius; + + /** + * @param glass the colour of the unlit screen — near black, never black. A + * true black cannot be shaded, so the well would have no walls. + * @param depthPx how deep the recess is; the width of the shaded wall. + * @param radiusPx corner radius, matched to the keys so the panel reads as one + * design rather than as two. + */ + public Display(int glass, float depthPx, float radiusPx, Skin.Screen manner, int edge) { + this(glass, depthPx, radiusPx, manner, edge, false); + } + + public Display withBezel(int colour) { + this.bezel = colour; + return this; + } + + public Display(int glass, float depthPx, float radiusPx, Skin.Screen manner, int edge, + boolean crt) { + this.crt = crt; + this.glass = glass; + this.manner = manner; + this.edge = edge; + // A framed screen has no wall to speak of: the line around it is a line, + // the same weight as the line around a key, not a modelled thickness. + this.depth = manner == Skin.Screen.FRAMED ? 1 : (int) Math.max(1f, depthPx); + this.radius = radiusPx; + wall.setStyle(Paint.Style.STROKE); + wall.setStrokeWidth(this.depth); + } + + @Override + protected void onBoundsChange(Rect bounds) { + face.set(bounds.left + depth * 0.5f, bounds.top + depth * 0.5f, + bounds.right - depth * 0.5f, bounds.bottom - depth * 0.5f); + + // Darker at the top, where the recess is deepest in shadow, lifting very + // slightly towards the bottom where the lower wall bounces light back in. + body.setShader(new LinearGradient(0, face.top, 0, face.bottom, + shade(glass, -3), shade(glass, 6), Shader.TileMode.CLAMP)); + + switch (manner) { + case FRAMED: + // One flat line in the accent, at the alpha the panel's own keys are + // outlined with. Nothing is being modelled here, so nothing gradates. + wall.setShader(null); + wall.setColor(Color.argb(150, Color.red(edge), Color.green(edge), + Color.blue(edge))); + break; + case INSET: + // Only the shadow along the top wall. The reflected light on the + // lower one is what sells a deep recess, and claiming a deep recess + // in a skin whose keys are 1px proud is claiming too much. + wall.setShader(new LinearGradient(0, face.top, 0, face.bottom, + new int[]{Color.argb(120, 0, 0, 0), Color.TRANSPARENT}, + new float[]{0f, 0.6f}, Shader.TileMode.CLAMP)); + break; + default: + // The wall of the well: shadow along the top, light along the bottom. + // The exact inverse of a key's bevel, deliberately — the two are + // drawn from one description of where the lamp is. + wall.setShader(new LinearGradient(0, face.top, 0, face.bottom, + new int[]{Color.argb(150, 0, 0, 0), Color.argb(20, 0, 0, 0), + Color.argb(40, 255, 255, 255)}, + new float[]{0f, 0.55f, 1f}, Shader.TileMode.CLAMP)); + break; + } + + sheen.setShader(new LinearGradient(face.left, face.top, face.right, face.bottom * 0.5f, + new int[]{Color.argb(16, 255, 255, 255), Color.TRANSPARENT}, + new float[]{0f, 1f}, Shader.TileMode.CLAMP)); + + if (crt) { + // Darkening towards the edges, the way a tube falls off away from the + // centre of its deflection. Radial rather than linear, because the + // falloff is about distance from the middle in both axes. + float cx = face.centerX(); + float cy = face.centerY(); + float r = Math.max(face.width(), face.height()) * 0.62f; + vignette.setShader(new android.graphics.RadialGradient(cx, cy, Math.max(1f, r), + new int[]{Color.TRANSPARENT, Color.argb(58, 0, 0, 0)}, + new float[]{0.55f, 1f}, Shader.TileMode.CLAMP)); + + // radial-gradient(120% 90% at 50% 45%, rgba(20,90,45,.14), transparent 70%) + // + // Taken from the reference sheet rather than approximated, including + // the offset centre: the bright spot of a tube sits a little above the + // middle, because the deflection yoke is not symmetrical about it. + // .14 alpha is 36 of 255; the stop at 70% is where it reaches nothing. + float haloX = face.left + face.width() * 0.50f; + float haloY = face.top + face.height() * 0.45f; + float haloR = Math.max(face.width() * 1.20f, face.height() * 0.90f) * 0.5f; + halo.setShader(new android.graphics.RadialGradient(haloX, haloY, + Math.max(1f, haloR), + new int[]{Color.argb(36, 20, 90, 45), Color.TRANSPARENT}, + new float[]{0f, 0.70f}, Shader.TileMode.CLAMP)); + } + } + + @Override + public void draw(Canvas canvas) { + canvas.drawRoundRect(face, radius, radius, body); + // No reflection on a framed screen: there is no glass over it to reflect + // anything, only an outlined region of the deck. + if (manner != Skin.Screen.FRAMED) { + canvas.drawRoundRect(face, radius, radius, sheen); + } + if (crt) { + canvas.save(); + canvas.clipRect(face); + + // Two lines per period, not one: a faintly green band and a faintly + // black one, two device pixels each. That is what the reference sheet + // asks for and it is also what a shadow mask actually does — the gaps + // between rows are not black, they are the phosphor not being struck, + // so they keep the tube's colour. A single dark line every third pixel + // reads as a dirty screen; this reads as a raster. + // linear-gradient(rgba(10,70,30,.045) 50%, rgba(0,0,0,.045) 50%) + // at background-size 100% 4px: two device pixels of each, and .045 + // alpha is 11 of 255. + for (float y = face.top; y < face.bottom; y += 4f) { + scan.setColor(Color.argb(11, 10, 70, 30)); + canvas.drawRect(face.left, y, face.right, y + 2f, scan); + scan.setColor(Color.argb(11, 0, 0, 0)); + canvas.drawRect(face.left, y + 2f, face.right, y + 4f, scan); + } + + // The tube's own glow, brightest where the beam spends most of its + // time. Drawn before the vignette so the two meet in the middle + // distance rather than fighting at the edge. + canvas.drawRoundRect(face, radius, radius, halo); + canvas.drawRoundRect(face, radius, radius, vignette); + canvas.restore(); + } + + canvas.drawRoundRect(face, radius, radius, wall); + + if (bezel != 0) { + // The frame of the glass, one pixel, in the screen's own colour family. + // The shaded wall above does the depth; this only says where the glass + // ends — and it has to be a hairline, because a thick frame around a + // panel of text is read as a border and starts competing with it. + wall.setShader(null); + wall.setStrokeWidth(1f); + wall.setColor(bezel); + canvas.drawRoundRect(face, radius, radius, wall); + } + } + + private static int shade(int colour, int step) { + return Color.rgb( + Math.max(0, Math.min(255, Color.red(colour) + step)), + Math.max(0, Math.min(255, Color.green(colour) + step)), + Math.max(0, Math.min(255, Color.blue(colour) + step))); + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(ColorFilter filter) { + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/Fonts.java b/client/app/src/main/java/dev/butschster/linuxterminal/Fonts.java new file mode 100644 index 0000000..5362e82 --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/Fonts.java @@ -0,0 +1,83 @@ +package dev.butschster.linuxterminal; + +import android.content.Context; +import android.graphics.Typeface; + +import java.util.HashMap; +import java.util.Map; + +/** + * The one monospaced face the whole app is set in. + * + *

It used to be {@link Typeface#MONOSPACE}, which sounds neutral and is not: on + * Horizon OS that alias resolves to DroidSansMono.ttf — checked on the + * headset's own {@code /system/etc/fonts.xml}, not assumed — a face drawn for + * Android 2 and shipped since out of compatibility. It is narrow, low in + * x-height, and it does not look like a terminal because it was never meant to be + * one. + * + *

That matters more here than it would on a phone. `docs/readability.md` puts + * the comfort threshold at about 0.39° of angular size per glyph and the failure + * point near 0.31°, and those numbers were measured with DejaVu Sans Mono. + * Every conclusion in that file therefore describes this face and not the one the + * client was actually drawing with, so bundling it is not a preference — it makes + * the measurements apply to what is on screen. + * + *

Bundled rather than asked of the system: a system alias can resolve to + * anything on any device, and a bar whose columns depend on which build of Horizon + * OS the headset is running is not a bar anybody can measure. + * + *

An asset, not a dependency. The "no new dependencies" rule is about code that + * has to keep working — a font file has no version, no transitive graph and no + * behaviour. + */ +public final class Fonts { + + /** + * Measured with, and therefore the default. Bitstream Vera / DejaVu licence, + * which permits redistribution; the notice belongs in NOTICE.md. + */ + public static final String DEJAVU = "DejaVuSansMono.ttf"; + + /** + * Drawn for reading code for hours: about 10% more x-height at the same em, and + * 0/O and 1/l/I deliberately pulled apart. SIL Open Font Licence. + * + *

The higher x-height is the interesting part for this project — angular + * size is what readability follows, and x-height is what angular size is + * actually spent on in a lowercase path like {@code ~/repos/home}. + */ + public static final String JETBRAINS = "JetBrainsMono-Regular.ttf"; + + /** What the system hands back for "monospace". Kept only for comparison. */ + public static final String SYSTEM = ""; + + private static final Map loaded = new HashMap<>(); + + private static String chosen = DEJAVU; + + private Fonts() {} + + public static void choose(String asset) { + chosen = asset; + } + + public static String chosen() { + return chosen; + } + + /** + * The face to draw with. Cached: {@code createFromAsset} parses the file every + * call, and this bar rebuilds its left half whenever the foreground process + * changes — several times a second while a program is starting. + */ + public static synchronized Typeface mono(Context context) { + if (chosen == null || chosen.isEmpty()) return Typeface.MONOSPACE; + Typeface face = loaded.get(chosen); + if (face == null) { + face = Typeface.createFromAsset(context.getAssets(), chosen); + loaded.put(chosen, face); + } + return face; + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/Glyphs.java b/client/app/src/main/java/dev/butschster/linuxterminal/Glyphs.java index 751aeb8..b95929c 100644 --- a/client/app/src/main/java/dev/butschster/linuxterminal/Glyphs.java +++ b/client/app/src/main/java/dev/butschster/linuxterminal/Glyphs.java @@ -197,27 +197,44 @@ private void drawBackspace(Canvas canvas, Rect bounds) { * KeyPad (a context action marked "runs on press") that still typed a * Unicode glyph instead of drawing one. */ + /** + * The return mark: a stem down the right, a bar left along the bottom, + * and an arrowhead on the end of the bar. + * + *

Rewritten because the first version was unreadable at this size and + * the reason is worth keeping. Its arrowhead was built from two arms of + * different lengths meeting at a shallow angle, so at a stroke width of an + * eighth of the box the two nearly merged and the tip filled in — a blob + * with a tail rather than an arrow. Here the arms are the same length and + * meet at a right angle, symmetric about the bar, which is the one shape + * that stays an arrow when the ink spreads: the notch between the arms + * cannot close while they are 90° apart. + * + *

The bar sits an arm's length above the bottom rather than on it, so + * the lower arm has somewhere to go. Everything is proportional to the box, + * so the mark holds together at any size the bar asks for. + */ private void drawEnter(Canvas canvas, Rect bounds) { float half = paint.getStrokeWidth() / 2f; - float left = bounds.left + half + bounds.width() * 0.16f; - float right = bounds.right - half - bounds.width() * 0.16f; - float top = bounds.top + half + bounds.height() * 0.18f; - float bottom = bounds.bottom - half - bounds.height() * 0.18f; - float hookX = left + (right - left) * 0.32f; + float left = bounds.left + half + bounds.width() * 0.14f; + float right = bounds.right - half - bounds.width() * 0.14f; + float top = bounds.top + half + bounds.height() * 0.14f; + float bottom = bounds.bottom - half - bounds.height() * 0.14f; + + float arm = (bottom - top) * 0.28f; + float barY = bottom - arm; + float tipX = left + arm; Path stem = new Path(); stem.moveTo(right, top); - stem.lineTo(right, bottom); - stem.lineTo(hookX, bottom); + stem.lineTo(right, barY); + stem.lineTo(tipX, barY); canvas.drawPath(stem, paint); - float armX = hookX + (right - hookX) * 0.42f; - float armTopY = top + (bottom - top) * 0.30f; - float armBottomY = bottom - (bottom - top) * 0.05f; Path head = new Path(); - head.moveTo(armX, armTopY); - head.lineTo(hookX, bottom); - head.lineTo(armX, armBottomY); + head.moveTo(tipX + arm, barY - arm); + head.lineTo(tipX, barY); + head.lineTo(tipX + arm, barY + arm); canvas.drawPath(head, paint); } diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/KeyFace.java b/client/app/src/main/java/dev/butschster/linuxterminal/KeyFace.java new file mode 100644 index 0000000..3f0628f --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/KeyFace.java @@ -0,0 +1,146 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +/** + * A key drawn as an object rather than as a rectangle: a moulded cap with a + * bevel, sitting on a shadow, with the grain and the scratches of the panel it + * belongs to. + * + *

Everything here describes one light source, above and slightly in front, + * which is what a bar mounted below eye level actually gets. That single decision + * settles every gradient in the file: the face is lighter at the top, the bevel + * catches light on its upper edge and shadow on its lower one, and the cast + * shadow falls downwards. A shape lit two ways reads as a drawing of a shape. + * + *

Pressed inverts it. Not "lighter", which is what a flat design does — the + * highlight moves to the bottom edge, the cast shadow disappears because the cap + * is now flush with the deck, and the face darkens slightly because it has moved + * away from the light. Those three together are what a pressed key looks like, + * and each of them on its own is not. + * + *

The cost of all this is contrast spent on the object instead of on the + * legend, and `docs/readability.md` is the reason to keep the amounts small: the + * bevel is a single device-independent pixel, the texture sits under 5% alpha, + * and the face gradient spans about 8%. On a monitor it reads as material; the + * question the headset has to answer is whether it survives the lenses or turns + * into mud. + */ +public class KeyFace extends Drawable { + + private final int fill; + private final float radius; + private final float shadow; + private final boolean pressed; + private final boolean textured; + + private final Paint body = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint bevel = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint texture = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint cast = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final RectF face = new RectF(); + private final RectF under = new RectF(); + + /** + * @param shadowPx how far the cap stands above the deck, in pixels. Drawn + * inside the view's own bounds — an elevation shadow falls + * outside the child, so every container in the bar would have + * to stop clipping and one that was missed would crop it. + */ + public KeyFace(int fill, float radiusPx, float shadowPx, boolean pressed, boolean textured) { + this.fill = fill; + this.radius = radiusPx; + this.shadow = shadowPx; + this.pressed = pressed; + this.textured = textured; + + bevel.setStyle(Paint.Style.STROKE); + cast.setColor(Color.argb(150, 0, 0, 0)); + } + + @Override + protected void onBoundsChange(android.graphics.Rect bounds) { + // Half a pixel in, so a 1px stroke lands on the pixel rather than across + // two of them: an antialiased edge straddling a boundary is drawn at half + // strength on each side, which is exactly how a crisp bevel turns into a + // grey smear. + face.set(bounds.left + 0.5f, bounds.top + 0.5f, + bounds.right - 0.5f, bounds.bottom - (pressed ? 0.5f : shadow + 0.5f)); + under.set(bounds.left + 0.5f, bounds.top + shadow, + bounds.right - 0.5f, bounds.bottom - 0.5f); + + int top = shade(fill, pressed ? -6 : 12); + int bottom = shade(fill, pressed ? -20 : -10); + body.setShader(new LinearGradient(0, face.top, 0, face.bottom, + top, bottom, Shader.TileMode.CLAMP)); + + // One shader for both edges of the bevel: white at the top fading out by + // the middle, black arriving again at the bottom. Two paints would drift + // apart the moment either was adjusted. + bevel.setShader(new LinearGradient(0, face.top, 0, face.bottom, + new int[]{ + pressed ? Color.argb(90, 0, 0, 0) : Color.argb(58, 255, 255, 255), + Color.TRANSPARENT, + pressed ? Color.argb(46, 255, 255, 255) : Color.argb(96, 0, 0, 0), + }, + new float[]{0f, 0.5f, 1f}, Shader.TileMode.CLAMP)); + } + + @Override + public void draw(Canvas canvas) { + // The deck showing under the cap. Skipped when pressed: the cap is down. + if (!pressed && shadow >= 1f) { + canvas.drawRoundRect(under, radius, radius, cast); + } + + canvas.drawRoundRect(face, radius, radius, body); + + if (textured) { + // Clipped to the cap, so the grain stops where the object does. Alpha + // rather than a colour: the same two tiles then work over every fill + // in the palette without tinting any of them. + canvas.save(); + canvas.clipRect(face); + texture.setShader(Wear.grain()); + texture.setAlpha(pressed ? 12 : 17); + canvas.drawRoundRect(face, radius, radius, texture); + texture.setShader(Wear.use()); + texture.setAlpha(pressed ? 18 : 27); + canvas.drawRoundRect(face, radius, radius, texture); + canvas.restore(); + } + + bevel.setStrokeWidth(Math.max(1f, shadow * 0.5f)); + canvas.drawRoundRect(face, radius, radius, bevel); + } + + /** Lighter or darker by an equal step on every channel, so the hue does not drift. */ + private static int shade(int colour, int step) { + return Color.rgb( + Math.max(0, Math.min(255, Color.red(colour) + step)), + Math.max(0, Math.min(255, Color.green(colour) + step)), + Math.max(0, Math.min(255, Color.blue(colour) + step))); + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(ColorFilter filter) { + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/KeyPad.java b/client/app/src/main/java/dev/butschster/linuxterminal/KeyPad.java index 9c2387c..510452a 100644 --- a/client/app/src/main/java/dev/butschster/linuxterminal/KeyPad.java +++ b/client/app/src/main/java/dev/butschster/linuxterminal/KeyPad.java @@ -55,12 +55,17 @@ public KeyPad(Context context, Buttons buttons, Send send) { // Nothing is left empty and the cluster still reads as the inverted T every // keyboard has, because the arrows carry a colour of their own — the shape // survives having neighbours. - addView(row( - key("^W", "\u0017", Buttons.KEY, "delete the word before the cursor"), - repeating(Glyphs.Kind.UP, ESC + "[A", Buttons.ARROW, "up — previous command"), - key("^U", "\u0015", Buttons.KEY, "delete to the start of the line"))); - - addView(row( + // The arrow block: ^W and ^U keep their columns, the up key keeps its cell, + // and one T-shaped cut-out is drawn under all four arrows. Grouping by + // changing the housing rather than by moving anything — see Buttons.tee. + View up = repeating(Glyphs.Kind.UP, ESC + "[A", Buttons.ARROW, "up — previous command"); + addView(buttons.tee( + new View[]{ + key("^W", "\u0017", Buttons.KEY, "delete the word before the cursor"), + up, + key("^U", "\u0015", Buttons.KEY, "delete to the start of the line"), + }, + 1, repeating(Glyphs.Kind.LEFT, ESC + "[D", Buttons.ARROW, "left"), repeating(Glyphs.Kind.DOWN, ESC + "[B", Buttons.ARROW, "down"), repeating(Glyphs.Kind.RIGHT, ESC + "[C", Buttons.ARROW, "right"))); @@ -72,7 +77,7 @@ public KeyPad(Context context, Buttons buttons, Send send) { // Enter takes the full width at the bottom, where two of its four error // directions are the window edge and a miss costs nothing. - addView(row(key("Enter", "\r", Buttons.ENTER, "run it"))); + addView(row(buttons.launchKey("Enter", "run it", v -> send.send("\r")))); } /** @@ -104,6 +109,23 @@ private View repeating(Glyphs.Kind kind, String bytes, int style, String hint) { return view; } + /** + * A row that touches the one above it: the arrow cluster meeting the up key. + * + *

Same cells, same widths, same positions — only the margin between the two + * rows is gone, so the block below sits against the key above and the pair + * reads as one arrow cluster in the shape a keyboard uses. + */ + private LinearLayout joined(View cell) { + LinearLayout row = new LinearLayout(getContext()); + row.setOrientation(HORIZONTAL); + LayoutParams params = new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, 1f); + params.setMargins(buttons.gap() / 2, 0, buttons.gap() / 2, buttons.gap() / 2); + row.addView(cell, params); + return row; + } + private LinearLayout row(View... cells) { LinearLayout row = new LinearLayout(getContext()); row.setOrientation(HORIZONTAL); diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/Keycap.java b/client/app/src/main/java/dev/butschster/linuxterminal/Keycap.java new file mode 100644 index 0000000..d8a17ca --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/Keycap.java @@ -0,0 +1,177 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +/** + * A sculpted keycap in a recessed retainer: the most keyboard-like of the + * shapes, and the one Enter now wears. + * + *

Three parts, and each does a job the others cannot. The retainer is a + * shallow pocket in the plate with the cap sitting inside it, so the key is a + * mounted part rather than a rectangle painted on. The cap carries a + * three-stop gradient — bright across the top third, mid through the middle, dark + * at the foot — which is what a dish-shaped moulding does to light coming from + * above. And the foot below it is solid, unblurred, six pixels tall: that + * is the height of the cap, and an unblurred copy of a shape offset downwards is + * the only thing that reads as the side of a solid rather than as a shadow it + * casts. + * + *

Pressed, the cap travels the full six pixels into the retainer and the foot + * collapses to nothing, so the key ends flush with the pocket it lives in. That + * is a longer throw than any other control on the panel and it is deliberate: + * this is the key you commit with, and it should feel like it went somewhere. + * + *

The colours come from the skin rather than from this file. A cap on the flat + * panel and a cap on the machined one are the same object under different light, + * and hardcoding the greys here would have made Enter the one control that + * ignores which panel it is mounted in. + */ +public class Keycap extends Drawable { + + /** Which half of the assembly this drawable is: the pocket, or the cap in it. */ + public enum Part { RETAINER, CAP } + + private final Part part; + private final int face; + private final int edge; + private final float radius; + private final float travel; + private final boolean pressed; + + private final Paint retainer = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint pocket = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint foot = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint ambient = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint body = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint dish = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint lip = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint rim = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final RectF well = new RectF(); + private final RectF cap = new RectF(); + private final RectF stand = new RectF(); + + public Keycap(Part part, int face, int edge, int plate, float radiusPx, float travelPx, + boolean pressed) { + this.part = part; + this.face = face; + this.edge = edge; + this.radius = radiusPx; + this.travel = travelPx; + this.pressed = pressed; + + retainer.setColor(shade(plate, -10)); + foot.setColor(shade(plate, -18)); + pocket.setStyle(Paint.Style.STROKE); + lip.setStyle(Paint.Style.STROKE); + rim.setStyle(Paint.Style.STROKE); + rim.setColor(edge); + } + + @Override + protected void onBoundsChange(Rect bounds) { + well.set(bounds.left + 0.5f, bounds.top + 0.5f, + bounds.right - 0.5f, bounds.bottom - 0.5f); + + // The cap does not move within its own bounds. The view carries it down — + // see Buttons.travel — because the legend is drawn by the view, and a cap + // that travelled inside its background would slide out from under its own + // label. What changes on press is only what is under it: the foot goes. + float inset = part == Part.RETAINER ? 0f : 0f; + cap.set(well.left + inset, well.top + inset, + well.right - inset, well.bottom - inset - travel); + stand.set(cap.left, cap.top + travel, cap.right, cap.bottom + travel); + + // inset 0 2px 6px rgba(0,0,0,.9) — the pocket's own shadow. + pocket.setShader(new LinearGradient(0, well.top, 0, well.bottom, + new int[]{0xE6000000, Color.TRANSPARENT}, + new float[]{0f, 0.5f}, Shader.TileMode.CLAMP)); + + // 180deg: bright top third, mid, dark foot — a dished moulding under a + // light that is above and in front. + body.setShader(new LinearGradient(0, cap.top, 0, cap.bottom, + new int[]{lighten(face, 0.22f), face, shade(face, -22)}, + new float[]{0f, 0.42f, 1f}, Shader.TileMode.CLAMP)); + + // inset 0 -12px 18px rgba(0,0,0,.35): the dish, darkening towards the foot + // of the cap where the surface curves away. + dish.setShader(new LinearGradient(0, cap.bottom - travel * 2.5f, 0, cap.bottom, + new int[]{Color.TRANSPARENT, 0x59000000}, + new float[]{0f, 1f}, Shader.TileMode.CLAMP)); + + lip.setShader(new LinearGradient(0, cap.top, 0, cap.top + 3f, + new int[]{pressed ? 0x1A000000 : 0x29FFFFFF, Color.TRANSPARENT}, + new float[]{0f, 1f}, Shader.TileMode.CLAMP)); + } + + @Override + public void draw(Canvas canvas) { + if (part == Part.RETAINER) { + canvas.drawRoundRect(well, radius, radius, retainer); + canvas.save(); + canvas.clipRect(well); + pocket.setStrokeWidth(6f); + canvas.drawRoundRect(well, radius, radius, pocket); + canvas.restore(); + return; + } + + if (!pressed) { + for (int i = 3; i >= 1; i--) { + ambient.setColor(Color.argb(20, 0, 0, 0)); + RectF soft = new RectF(stand); + soft.inset(-i * 1.5f, -i * 1.5f); + soft.offset(0, i * 1.3f); + canvas.drawRoundRect(soft, radius + i, radius + i, ambient); + } + canvas.drawRoundRect(stand, radius - 2f, radius - 2f, foot); + } + + canvas.drawRoundRect(cap, radius - 2f, radius - 2f, body); + canvas.save(); + canvas.clipRect(cap); + canvas.drawRoundRect(cap, radius - 2f, radius - 2f, dish); + lip.setStrokeWidth(3f); + canvas.drawRoundRect(cap, radius - 2f, radius - 2f, lip); + canvas.restore(); + + rim.setStrokeWidth(1f); + canvas.drawRoundRect(cap, radius - 2f, radius - 2f, rim); + } + + private static int shade(int colour, int step) { + return Color.rgb( + Math.max(0, Math.min(255, Color.red(colour) + step)), + Math.max(0, Math.min(255, Color.green(colour) + step)), + Math.max(0, Math.min(255, Color.blue(colour) + step))); + } + + private static int lighten(int colour, float amount) { + return Color.rgb( + (int) (Color.red(colour) + (255 - Color.red(colour)) * amount), + (int) (Color.green(colour) + (255 - Color.green(colour)) * amount), + (int) (Color.blue(colour) + (255 - Color.blue(colour)) * amount)); + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(ColorFilter filter) { + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/LaunchKey.java b/client/app/src/main/java/dev/butschster/linuxterminal/LaunchKey.java new file mode 100644 index 0000000..58b6e53 --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/LaunchKey.java @@ -0,0 +1,192 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +/** + * The one key on the panel that is a different object: the launch key. + * + *

Every other control is a cap in a family of caps, told apart by colour and + * legend. Enter is the key you press to commit — the end of every sentence typed + * here — and on a real console that key is not a member of the grid. It is longer, + * it is set in its own bezel, and it carries a groove around its face so the + * finger finds it without the eye. A machine that has one irreversible action + * gives it a shape nothing else has, so that "which one was it" is never a + * question. + * + *

Three things make it that shape, and none of them is a colour: + * + *

+ */ +public class LaunchKey extends Drawable { + + private final int face; + private final int accent; + private final float depth; + private final boolean pressed; + + private final Paint bezel = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint solid = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint body = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint groove = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint rim = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint collarWall = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint knurl = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final RectF housing = new RectF(); + private final RectF cap = new RectF(); + private final RectF extrusion = new RectF(); + private final RectF inlay = new RectF(); + + private float radius; + private float inlayRadius; + + public LaunchKey(int face, int accent, float depthPx, boolean pressed) { + this.face = face; + this.accent = accent; + this.depth = depthPx; + this.pressed = pressed; + + bezel.setColor(0xFF0A1119); + solid.setColor(0xE0071009); + groove.setStyle(Paint.Style.STROKE); + rim.setStyle(Paint.Style.STROKE); + glow.setStyle(Paint.Style.STROKE); + collarWall.setStyle(Paint.Style.STROKE); + knurl.setStyle(Paint.Style.STROKE); + } + + @Override + protected void onBoundsChange(Rect bounds) { + housing.set(bounds.left + 0.5f, bounds.top + 0.5f, + bounds.right - 0.5f, bounds.bottom - 0.5f); + + // A deep collar rather than a hairline bezel: this is a button seated in a + // hole cut through the panel, the way a launch or arm control is mounted. + // The collar is what your eye reads as thickness of plate, so it has to be + // several pixels, not one. + float collar = 7f; + float stand = pressed ? Math.min(depth, 1f) : depth; + cap.set(housing.left + collar, housing.top + collar, + housing.right - collar, housing.bottom - collar - depth); + extrusion.set(cap.left, cap.top + stand, cap.right, cap.bottom + stand); + + // Square-shouldered, not a capsule. A capsule reads as a bar key — a wide + // thing you slap — and this is the opposite claim: one deliberate control, + // seated, pressed once. Its corners are the panel's own radius so it is + // still made of the same material as everything else. + radius = 9f; + + inlay.set(cap.left + 7f, cap.top + 6f, cap.right - 7f, cap.bottom - 6f); + inlayRadius = Math.max(2f, radius - 3f); + + body.setShader(new LinearGradient(0, cap.top, 0, cap.bottom, + pressed + ? new int[]{shade(face, -14), shade(face, -4), shade(face, 6)} + : new int[]{lighten(face, 0.20f), face, shade(face, -14)}, + new float[]{0f, 0.5f, 1f}, Shader.TileMode.CLAMP)); + + // The groove: shadow on its upper wall, light on its lower one — the same + // description every recess on this panel is drawn from. + groove.setShader(new LinearGradient(0, inlay.top, 0, inlay.bottom, + new int[]{0x8C000000, 0x1AFFFFFF}, + new float[]{0f, 1f}, Shader.TileMode.CLAMP)); + + collarWall.setShader(new LinearGradient(0, housing.top, 0, housing.bottom, + new int[]{0xCC000000, Color.TRANSPARENT, 0x0FFFFFFF}, + new float[]{0f, 0.4f, 1f}, Shader.TileMode.CLAMP)); + } + + @Override + public void draw(Canvas canvas) { + // The hole in the plate, and the shaded wall of it: the same recess this + // panel draws everywhere, at the size a mounted control needs. + canvas.drawRoundRect(housing, radius + 7f, radius + 7f, bezel); + canvas.save(); + canvas.clipRect(housing); + collarWall.setStrokeWidth(6f); + canvas.drawRoundRect(housing, radius + 7f, radius + 7f, collarWall); + canvas.restore(); + + if (!pressed) { + canvas.drawRoundRect(extrusion, radius, radius, solid); + } + + canvas.drawRoundRect(cap, radius, radius, body); + + groove.setStrokeWidth(1.5f); + canvas.drawRoundRect(inlay, inlayRadius, inlayRadius, groove); + + // Knurling down both shoulders — the grip a guarded control has so a glove + // finds it. Four short strokes a side, inside the groove, at the same + // alpha as the bevel so it reads as moulded rather than printed. + knurl.setStrokeWidth(1f); + float step = cap.height() / 5f; + for (int i = 1; i <= 4; i++) { + float y = cap.top + step * i; + knurl.setColor(0x1AFFFFFF); + canvas.drawLine(inlay.left + 3f, y, inlay.left + 9f, y, knurl); + canvas.drawLine(inlay.right - 9f, y, inlay.right - 3f, y, knurl); + } + + // Lit at rest, because this is the key the panel is built around. Two + // rings rather than a blur, and quiet: it marks the primary control, it + // does not shout over the legend on it. + glow.setStrokeWidth(3f); + glow.setColor(withAlpha(accent, pressed ? 30 : 20)); + canvas.drawRoundRect(cap, radius, radius, glow); + + rim.setStrokeWidth(1f); + rim.setColor(withAlpha(accent, pressed ? 190 : 150)); + canvas.drawRoundRect(cap, radius, radius, rim); + } + + private static int withAlpha(int colour, int alpha) { + return Color.argb(alpha, Color.red(colour), Color.green(colour), Color.blue(colour)); + } + + private static int shade(int colour, int step) { + return Color.rgb( + Math.max(0, Math.min(255, Color.red(colour) + step)), + Math.max(0, Math.min(255, Color.green(colour) + step)), + Math.max(0, Math.min(255, Color.blue(colour) + step))); + } + + private static int lighten(int colour, float amount) { + return Color.rgb( + (int) (Color.red(colour) + (255 - Color.red(colour)) * amount), + (int) (Color.green(colour) + (255 - Color.green(colour)) * amount), + (int) (Color.blue(colour) + (255 - Color.blue(colour)) * amount)); + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(ColorFilter filter) { + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/Led.java b/client/app/src/main/java/dev/butschster/linuxterminal/Led.java new file mode 100644 index 0000000..2ace088 --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/Led.java @@ -0,0 +1,64 @@ +package dev.butschster.linuxterminal; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.view.View; + +/** + * A status lamp: a small dot that is either dark or lit with a halo around it. + * + *

It reports one bit and says so instantly, which is the whole reason a panel + * has lamps rather than another line of text. Unlit it is not black but a dark + * version of its own colour — an unlit LED still has a coloured lens, and a black + * dot reads as a hole in the panel. + */ +public class Led extends View { + + private final int colour; + private boolean on; + + private final Paint lens = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint halo = new Paint(Paint.ANTI_ALIAS_FLAG); + + public Led(Context context, int colour, boolean on) { + super(context); + this.colour = colour; + this.on = on; + } + + public void setOn(boolean lit) { + this.on = lit; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + float cx = getWidth() / 2f; + float cy = getHeight() / 2f; + float r = Math.min(cx, cy); + + if (on) { + // Two rings standing in for the bloom around a lit lamp. Drawn inside + // the view, so no parent has to stop clipping to show it. + halo.setColor(Color.argb(70, Color.red(colour), Color.green(colour), + Color.blue(colour))); + canvas.drawCircle(cx, cy, r, halo); + halo.setColor(Color.argb(120, Color.red(colour), Color.green(colour), + Color.blue(colour))); + canvas.drawCircle(cx, cy, r * 0.78f, halo); + } + + lens.setColor(on ? colour : dark(colour)); + canvas.drawCircle(cx, cy, r * 0.55f, lens); + } + + /** The lens with the lamp behind it off: the colour at a fifth, over the panel. */ + private static int dark(int colour) { + return Color.rgb( + (int) (Color.red(colour) * 0.22f) + 20, + (int) (Color.green(colour) * 0.22f) + 26, + (int) (Color.blue(colour) * 0.22f) + 22); + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/PhysicalKey.java b/client/app/src/main/java/dev/butschster/linuxterminal/PhysicalKey.java new file mode 100644 index 0000000..040cb95 --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/PhysicalKey.java @@ -0,0 +1,238 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +/** + * The whole recipe for a shallow physical control, in the order the layers are + * laid down: + * + *

+ *   ambient blur        soft, wide, well below the cap
+ *   solid extrusion     hard-edged, no blur — this is the cap's height
+ *   face                three-stop vertical gradient
+ *   top bevel           1px of white inside the upper edge
+ *   bottom bevel        1px of black inside the lower edge
+ *   border              1px, coloured by the control's role
+ * 
+ * + *

The solid extrusion is the layer that does the work and the one a flat design + * leaves out. A blurred shadow says "this floats"; an unblurred copy of the shape, + * offset downwards, says "this is a solid object of that height", because that is + * what the side of an extruded cap actually looks like. The blur underneath is the + * ambient light in the room, and it is the smaller effect of the two. + * + *

Pressing inverts the whole description at once rather than tinting it. The + * cap travels down (see {@code Buttons.travel}), the extrusion collapses to almost + * nothing, the face gradient flips so the light now comes from below, and a real + * inner shadow appears along the top edge — which is the shadow of the housing + * falling onto a cap that has gone into it. + * + *

Blur is faked by stacking translucent copies rather than by + * {@link android.graphics.BlurMaskFilter}: a mask filter forces the view onto a + * software layer, and the left half of this bar is rebuilt whenever the foreground + * process changes. At this radius the stack is indistinguishable and costs nothing. + */ +public class PhysicalKey extends Drawable { + + private final int face; + private final int border; + private final int legend; + private final float radius; + private final float depth; + private final boolean pressed; + + /** Extra illumination around the whole control: focus, or a primary action. */ + private final int halo; + + private final Paint body = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint solid = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint ambient = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint bevel = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint edge = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint inner = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final RectF cap = new RectF(); + private final RectF extrusion = new RectF(); + + /** + * Per-corner radii, when this key is one cell of a cluster. + * + *

A key sitting alone is rounded on all four corners. A key that is one of + * several inside a single housing is rounded only where it meets the outside + * world and square where it meets its neighbour — which is what makes a block + * of keys read as one part with seams rather than as separate caps that happen + * to be close together. Null means the ordinary all-round radius. + */ + private float[] radii; + + private final android.graphics.Path shape = new android.graphics.Path(); + + /** The same key, rounded only on the corners given. See {@link #radii}. */ + public PhysicalKey corners(float[] perCorner) { + this.radii = perCorner; + return this; + } + + public PhysicalKey(int face, int border, int legend, float radiusPx, float depthPx, + boolean pressed, int halo) { + this.face = face; + this.border = border; + this.legend = legend; + this.radius = radiusPx; + this.depth = depthPx; + this.pressed = pressed; + this.halo = halo; + + bevel.setStyle(Paint.Style.STROKE); + edge.setStyle(Paint.Style.STROKE); + inner.setStyle(Paint.Style.STROKE); + glow.setStyle(Paint.Style.STROKE); + solid.setColor(0xE0090D13); + } + + @Override + protected void onBoundsChange(Rect bounds) { + // The drawable is asked for the whole cell and gives the height back to the + // shadow. The view's own translationY does the travelling, so the cap keeps + // the same size in both states and only what is under it changes. + float stand = pressed ? Math.min(depth, 1f) : depth; + cap.set(bounds.left + 0.5f, bounds.top + 0.5f, + bounds.right - 0.5f, bounds.bottom - depth - 0.5f); + extrusion.set(cap.left, cap.top + stand, cap.right, cap.bottom + stand); + + int top, mid, low; + if (pressed) { + // 180deg #192330 → #222d3b: darker at the top now, because the cap has + // gone into the housing and the light no longer reaches its upper edge. + top = 0xFF192330; + mid = 0xFF1D2735; + low = 0xFF222D3B; + } else { + top = lighten(face, 0.14f); + mid = face; + low = shade(face, -12); + } + body.setShader(new LinearGradient(0, cap.top, 0, cap.bottom, + new int[]{top, mid, low}, new float[]{0f, 0.48f, 1f}, Shader.TileMode.CLAMP)); + + bevel.setShader(new LinearGradient(0, cap.top, 0, cap.bottom, + new int[]{ + pressed ? Color.TRANSPARENT : 0x1CFFFFFF, + Color.TRANSPARENT, + pressed ? 0x0AFFFFFF : 0x73000000, + }, + new float[]{0f, 0.5f, 1f}, Shader.TileMode.CLAMP)); + + // The housing's shadow falling onto a cap that is now inside it. Only when + // pressed: an unpressed cap stands proud of the housing and has nothing + // above it to cast one. + inner.setShader(new LinearGradient(0, cap.top, 0, cap.top + depth * 2f, + new int[]{0xA6000000, Color.TRANSPARENT}, + new float[]{0f, 1f}, Shader.TileMode.CLAMP)); + } + + @Override + public void draw(Canvas canvas) { + if (!pressed) { + // Ambient: three copies, each wider and fainter, standing in for a + // 9px blur at 35%. + for (int i = 3; i >= 1; i--) { + ambient.setColor(Color.argb(20, 0, 0, 0)); + RectF soft = new RectF(extrusion); + soft.inset(-i * 1.5f, -i * 1.5f); + soft.offset(0, i * 1.2f); + canvas.drawRoundRect(soft, radius + i, radius + i, ambient); + } + } + + // The height of the cap: hard-edged, so it reads as the side of a solid. + drawShape(canvas, extrusion, solid); + drawShape(canvas, cap, body); + + if (pressed) { + canvas.save(); + canvas.clipRect(cap); + inner.setStrokeWidth(depth); + drawShape(canvas, cap, inner); + canvas.restore(); + } + + bevel.setStrokeWidth(1f); + drawShape(canvas, cap, bevel); + + if (halo != 0) { + // Illumination is two rings, not a blur: the outer one is what a glow + // reads as at this size, and it stays inside the cell so no container + // has to stop clipping. + // Halved from the first attempt. Illumination marks which control is + // primary; at the old strength it was brighter than the legend it was + // meant to draw attention to, and the panel read as decorated. + glow.setStrokeWidth(3f); + glow.setColor(withAlpha(halo, 16)); + canvas.drawRoundRect(cap, radius, radius, glow); + glow.setStrokeWidth(1.5f); + glow.setColor(withAlpha(halo, 38)); + canvas.drawRoundRect(cap, radius, radius, glow); + } + + edge.setStrokeWidth(1f); + edge.setColor(border); + drawShape(canvas, cap, edge); + } + + private void drawShape(Canvas canvas, RectF rect, Paint paint) { + if (radii == null) { + canvas.drawRoundRect(rect, radius, radius, paint); + return; + } + shape.reset(); + shape.addRoundRect(rect, radii, android.graphics.Path.Direction.CW); + canvas.drawPath(shape, paint); + } + + /** The colour this control's legend is written in. */ + public int legend() { + return legend; + } + + private static int withAlpha(int colour, int alpha) { + return Color.argb(alpha, Color.red(colour), Color.green(colour), Color.blue(colour)); + } + + private static int shade(int colour, int step) { + return Color.rgb( + Math.max(0, Math.min(255, Color.red(colour) + step)), + Math.max(0, Math.min(255, Color.green(colour) + step)), + Math.max(0, Math.min(255, Color.blue(colour) + step))); + } + + private static int lighten(int colour, float amount) { + return Color.rgb( + (int) (Color.red(colour) + (255 - Color.red(colour)) * amount), + (int) (Color.green(colour) + (255 - Color.green(colour)) * amount), + (int) (Color.blue(colour) + (255 - Color.blue(colour)) * amount)); + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(ColorFilter filter) { + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/RockerHalf.java b/client/app/src/main/java/dev/butschster/linuxterminal/RockerHalf.java new file mode 100644 index 0000000..1682a19 --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/RockerHalf.java @@ -0,0 +1,131 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +/** + * One slope of a rocker: half of a cap that is pitched like a shallow roof, with + * the ridge between the two halves. + * + *

The two halves are lit as one object, not as two buttons that happen to + * touch. Light comes from above, so the upper slope faces it and is bright, the + * lower slope turns away and is dark, and the ridge between them is the brightest + * line on the control. That difference is the whole point: it says the thing + * pivots, and it says which way each end will go before it is touched. Two + * identically shaded halves are a split rectangle and read as two keys. + * + *

Only the outer corners are rounded. The pair share a straight edge at the + * ridge, because a rounded edge there would be a gap between two objects, and + * these are two faces of one. + * + *

Pressing a half swaps its gradient: the slope that went down now points away + * from the lamp, so it darkens, while the ridge slides towards it. The opposite + * half is left alone — a real rocker's other end rises, and that is drawn by its + * neighbour keeping the brightness it already had against a darkened partner. + */ +public class RockerHalf extends Drawable { + + private final boolean upper; + private final int fill; + private final float radius; + private final boolean pressed; + + private final Paint body = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint ridge = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final RectF face = new RectF(); + private final Path shape = new Path(); + + public RockerHalf(boolean upper, int fill, float radiusPx, boolean pressed) { + this.upper = upper; + this.fill = fill; + this.radius = radiusPx; + this.pressed = pressed; + ridge.setStyle(Paint.Style.STROKE); + ridge.setStrokeWidth(1f); + } + + @Override + protected void onBoundsChange(Rect bounds) { + face.set(bounds.left, bounds.top, bounds.right, bounds.bottom); + + float r = radius; + shape.reset(); + // Round away from the ridge only: the outer end of the control is a moulded + // corner, the inner end is where the two faces meet. + float[] corners = upper + ? new float[]{r, r, r, r, 0, 0, 0, 0} + : new float[]{0, 0, 0, 0, r, r, r, r}; + shape.addRoundRect(face, corners, Path.Direction.CW); + + int bright, dim; + if (upper) { + // Facing the lamp. Brightest at its outer edge, easing towards the ridge + // — a slope is lit most where it is most square to the light. + bright = lighten(fill, pressed ? 0.05f : 0.24f); + dim = lighten(fill, pressed ? 0.00f : 0.08f); + body.setShader(new LinearGradient(0, face.top, 0, face.bottom, + bright, dim, Shader.TileMode.CLAMP)); + } else { + // Turned away from it. Starts near the ridge at the material's own value + // and falls off to well under it. + bright = shade(fill, pressed ? -30 : -8); + dim = shade(fill, pressed ? -46 : -30); + body.setShader(new LinearGradient(0, face.top, 0, face.bottom, + bright, dim, Shader.TileMode.CLAMP)); + } + } + + @Override + public void draw(Canvas canvas) { + canvas.drawPath(shape, body); + + // The ridge itself. Drawn by the upper half only, along its lower edge, so + // there is exactly one line and it cannot end up doubled or a pixel apart. + if (upper) { + ridge.setColor(pressed ? 0x26FFFFFF : 0x59FFFFFF); + canvas.drawLine(face.left + radius * 0.5f, face.bottom - 0.5f, + face.right - radius * 0.5f, face.bottom - 0.5f, ridge); + } else { + ridge.setColor(0x8C000000); + canvas.drawLine(face.left + radius * 0.5f, face.top + 0.5f, + face.right - radius * 0.5f, face.top + 0.5f, ridge); + } + } + + private static int shade(int colour, int step) { + return Color.rgb( + Math.max(0, Math.min(255, Color.red(colour) + step)), + Math.max(0, Math.min(255, Color.green(colour) + step)), + Math.max(0, Math.min(255, Color.blue(colour) + step))); + } + + private static int lighten(int colour, float amount) { + return Color.rgb( + (int) (Color.red(colour) + (255 - Color.red(colour)) * amount), + (int) (Color.green(colour) + (255 - Color.green(colour)) * amount), + (int) (Color.blue(colour) + (255 - Color.blue(colour)) * amount)); + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(ColorFilter filter) { + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/Section.java b/client/app/src/main/java/dev/butschster/linuxterminal/Section.java new file mode 100644 index 0000000..c80ff37 --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/Section.java @@ -0,0 +1,101 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +/** + * A recessed region of the panel that a group of controls is mounted in. + * + *

It exists to give the panel four surfaces instead of two: panel → section → + * housing → face. With only two, everything on the plate is the same distance + * from the eye and the grouping has to be carried by whitespace and a caption, + * which is how a dashboard of cards reads rather than how a device reads. + * + *

It is not a card. A card sits on the surface and casts a shadow + * outwards; this is milled into it — the shadow is along the inside of + * its own top edge, and the only light is the thin line where the lower wall + * catches it. Cards would put every group at the same height as the keys standing + * in them, which is the arrangement this is meant to avoid. + */ +public class Section extends Drawable { + + private final int fill; + private final float radius; + + /** A lit rim along the top: this housing is furniture, not weather. */ + private boolean lit; + + private final Paint body = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint wall = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint edge = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final RectF face = new RectF(); + + public Section lit() { + this.lit = true; + edge.setColor(0x1FFFFFFF); + return this; + } + + public Section(int fill, float radiusPx) { + this.fill = fill; + this.radius = radiusPx; + body.setColor(fill); + wall.setStyle(Paint.Style.STROKE); + edge.setStyle(Paint.Style.STROKE); + edge.setStrokeWidth(1f); + edge.setColor(0x0AFFFFFF); + } + + @Override + protected void onBoundsChange(Rect bounds) { + face.set(bounds.left + 0.5f, bounds.top + 0.5f, + bounds.right - 0.5f, bounds.bottom - 0.5f); + wall.setShader(new LinearGradient(0, face.top, 0, face.bottom, + new int[]{0xBF000000, Color.TRANSPARENT, 0x08FFFFFF}, + new float[]{0f, 0.35f, 1f}, Shader.TileMode.CLAMP)); + } + + @Override + public void draw(Canvas canvas) { + canvas.drawRoundRect(face, radius, radius, body); + canvas.save(); + canvas.clipRect(face); + wall.setStrokeWidth(6f); + canvas.drawRoundRect(face, radius, radius, wall); + canvas.restore(); + canvas.drawRoundRect(face, radius, radius, edge); + + if (lit) { + // One hairline along the top edge only. A machined recess that is part + // of the chassis catches light where the plate was cut; a region the + // software refills does not get one, so the difference reads as how + // the panel was made rather than as a decoration applied to it. + edge.setColor(0x2EFFFFFF); + canvas.drawLine(face.left + radius, face.top + 0.5f, + face.right - radius, face.top + 0.5f, edge); + edge.setColor(lit ? 0x0AFFFFFF : 0); + } + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(ColorFilter filter) { + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/Skin.java b/client/app/src/main/java/dev/butschster/linuxterminal/Skin.java new file mode 100644 index 0000000..893c36c --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/Skin.java @@ -0,0 +1,574 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Color; + +/** + * What the bar is made of: colour, corner, and whether a key is a moulded cap or + * a lit outline. + * + *

This exists so the two answers can be held side by side and compared while + * wearing the headset, which is the only place the question can be settled. Every + * number the bar draws with used to be a constant in {@link Buttons}; they are + * here instead, and {@link Buttons#applySkin} copies the chosen set into place. + * + *

CAPPED is what the bar has always been: a solid moulded key with a + * darker lip along its bottom edge and its legend engraved into the face. The + * volume is physical — you read which way it would move. + * + *

OUTLINE is the other tradition: a near-black deck, keys drawn as thin + * lit rectangles, and one accent colour carrying every "this one does something". + * It reads as an instrument rather than as a keyboard. + * + *

Neither is obviously right for this display. Through pancake lenses the + * contrast that survives is luminance contrast, and the two skins spend it + * differently: CAPPED puts it in the fill and keeps the edges quiet, OUTLINE puts + * nearly all of it in a 1dp edge and leaves the fill almost as dark as the deck. + * A 1dp edge at arm's length is roughly 0.02° across — well under the 0.31° where + * `docs/readability.md` measured this project's own text becoming unreadable. That + * argument says the outline should fail; it is an argument, and the person wearing + * it outranks one, so both are built and looked at. + */ +public final class Skin { + + public enum Kind { CAPPED, OUTLINE, MACHINED, TACTILE, CONSOLE } + + public final Kind kind; + + /** The deck the dynamic half sits on, and the slightly different one for the fixed half. */ + public final int bg; + public final int bgPanel; + + /** Every line in the bar: the rules between groups and the dividers between panels. */ + public final int rule; + + public final int text; + public final int muted; + + /** Group headings. On OUTLINE they carry the accent; on CAPPED they stay muted. */ + public final int heading; + + /** The one colour that says "lit" — borders, indicators, the active state. */ + public final int accent; + + /** Fill per role, indexed by the style constants in {@link Buttons}. */ + public final int[] fill; + + /** Edge per role. Ignored when {@link #border} is zero. */ + public final int[] edge; + + public final int cornerDp; + + /** Edge width in dp. Zero means the key has no drawn edge at all. */ + public final int border; + + /** A darker copy showing below the cap. The moulded look; false on OUTLINE. */ + public final boolean lip; + + /** Legend cut into the face rather than printed on it. */ + public final boolean engrave; + + /** + * Rings of falling alpha just inside the edge, so a lit key looks lit rather + * than merely outlined. + * + *

Drawn as three concentric strokes rather than a real blur: a blur needs + * {@link android.graphics.BlurMaskFilter}, which forces the view onto a + * software layer, and this bar redraws its left half whenever the foreground + * process changes. Three strokes cost nothing and, at this size, read the same. + */ + public final boolean glow; + + /** Grain and scratches from {@link Wear}, on the plate and on every cap. */ + public final boolean textured; + + /** How far a cap stands above the plate, in dp. Zero is a flat key. */ + public final int depthDp; + + /** + * Whether the reading half is drawn as recessed displays rather than as groups + * of keys on the plate. + * + *

It is a claim about what those things are. Where you are, and the places + * you could go, are read before they are pressed; behind glass they stop + * competing with the keys for the same kind of attention. Only the skins with + * a material can carry it — a display sunk into a flat colour is just a darker + * rectangle. + */ + public final boolean displays; + + /** + * How a screen is set into this particular panel. + * + *

The idea is the same in all three — a thing you read, behind glass, told + * apart from the things you press — but a panel states it in its own material. + * A deep machined well in a flat skin would be a rendering of a well sitting on + * a surface that has no depth anywhere else, which reads as a mistake rather + * than as a screen. + */ + public enum Screen { + /** A shallow inset: shadow along the top wall, nothing else. */ + INSET, + /** No depth at all — the glass is outlined in the accent, like every key. */ + FRAMED, + /** A true recess with a lit lower wall. Only honest where the plate has material. */ + RECESSED, + } + + public final Screen screen; + + /** The unlit screen, and the phosphor lit behind it. */ + public final int glass; + public final int phosphor; + + /** + * Semantic accents. Named by what they mean, never by what they look like — + * a palette whose entries are "blue" and "green" gets used decoratively within + * a week, and then nothing on the panel means anything. + */ + public final int accentNav; + public final int accentGo; + public final int accentStop; + public final int accentWarn; + + /** How far a control travels when pressed, in dp. Zero means it does not move. */ + public final int travelDp; + + /** + * Whether places are drawn as a terminal listing with a cursor row rather than + * as a row of separate targets. + * + *

A listing is one control with N rows; a row of buttons is N controls. The + * first is what a small embedded display does and it reads as one device; the + * second is a toolbar that happens to be dark. + */ + public final boolean listing; + + /** Scanlines, vignette and a little bloom on lit text. */ + public final boolean crt; + + /** Legend colour per role. The role is told by the letter as well as the edge. */ + public final int[] legend; + + /** The dimmer phosphor, for anything on a screen that is not the subject. */ + public final int phosphorDim; + + /** + * Whether groups of keys sit in recessed sections rather than directly on the + * panel. + * + *

Four surfaces, in order: panel, recessed section, control housing, + * interactive face. Without the section the panel has two, and everything on + * it reads as one plane of buttons. + */ + public final boolean sections; + + private Skin(Builder b) { + this.kind = b.kind; + this.bg = b.bg; + this.bgPanel = b.bgPanel; + this.rule = b.rule; + this.text = b.text; + this.muted = b.muted; + this.heading = b.heading; + this.accent = b.accent; + this.fill = b.fill; + this.edge = b.edge; + this.cornerDp = b.cornerDp; + this.border = b.border; + this.lip = b.lip; + this.engrave = b.engrave; + this.glow = b.glow; + this.textured = b.textured; + this.depthDp = b.depthDp; + this.displays = b.displays; + this.screen = b.screen; + this.glass = b.glass; + this.phosphor = b.phosphor; + this.accentNav = b.accentNav != 0 ? b.accentNav : b.accent; + this.accentGo = b.accentGo != 0 ? b.accentGo : b.fill[4]; + this.accentStop = b.accentStop != 0 ? b.accentStop : b.fill[3]; + this.accentWarn = b.accentWarn != 0 ? b.accentWarn : b.fill[3]; + this.travelDp = b.travelDp; + this.listing = b.listing; + this.crt = b.crt; + // Every skin that did not name its legends writes them in its ordinary + // text colour, which is what all three did before this existed. + if (b.legend != null) { + this.legend = b.legend; + } else { + int[] plain = new int[b.fill.length]; + java.util.Arrays.fill(plain, b.text); + this.legend = plain; + } + this.phosphorDim = b.phosphorDim != 0 ? b.phosphorDim : b.muted; + this.sections = b.sections; + } + + // ------------------------------------------------------------------ the two + + /** + * The bar as it has always been. + * + *

Four roles, not eight decorative shades: an earlier palette had two greys + * sixteen units apart, which through pancake lenses is one grey. + */ + public static Skin capped() { + Builder b = new Builder(Kind.CAPPED); + b.bg = Color.rgb(24, 25, 31); + b.bgPanel = Color.rgb(31, 33, 40); + b.rule = Color.rgb(52, 54, 64); + b.text = Color.rgb(224, 226, 232); + b.muted = Color.rgb(150, 155, 168); + b.heading = b.muted; + b.accent = Color.rgb(40, 82, 128); + b.fill = new int[]{ + Color.rgb(58, 60, 70), // KEY — a literal keystroke + Color.rgb(52, 58, 74), // DESTINATION — somewhere to go + Color.rgb(40, 82, 128), // COMMAND — a line that runs + Color.rgb(122, 58, 30), // WARN — destructive + Color.rgb(38, 86, 62), // ENTER + Color.rgb(52, 118, 84), // VOICE + Color.rgb(74, 80, 104), // ARROW — the cluster found without looking + }; + b.edge = b.fill; // unused: border is zero + // Halved across every skin at once. A softer corner reads as consumer + // hardware; this is a tool, and the tighter break makes the caps read as + // parts rather than as pills. One number per skin, so they stay in step. + b.cornerDp = 4; + b.border = 0; + b.lip = true; + b.engrave = true; + b.glow = false; + b.displays = true; + b.screen = Screen.INSET; + // Darker than the plate by more than a shade — a screen in a flat skin has + // no wall to be read by, so the step in luminance is doing the whole job. + b.glass = Color.rgb(14, 15, 18); + // Amber, the colour of a gas-discharge panel. Not the skin's own blue: the + // blue is what a key that runs is coloured, and a screen that shared it + // would look like an enormous button. + b.phosphor = Color.rgb(255, 178, 84); + return new Skin(b); + } + + /** + * The instrument reading: a near-black deck and keys drawn as lit outlines. + * + *

The fills are deliberately close to the deck — the edge is what is meant + * to be seen. Roles are told apart by edge hue, and only the two that act on + * their own (COMMAND, ENTER) plus the two that are dangerous (WARN, and VOICE + * while it is live) are lit; a plain keystroke stays unlit, so a bar at rest + * has a handful of lit keys rather than forty. + */ + public static Skin outline() { + Builder b = new Builder(Kind.OUTLINE); + b.bg = Color.rgb(9, 13, 20); + b.bgPanel = Color.rgb(12, 17, 25); + b.rule = Color.rgb(28, 40, 55); + b.text = Color.rgb(205, 217, 232); + b.muted = Color.rgb(120, 138, 160); + b.accent = Color.rgb(56, 200, 235); + b.heading = b.accent; + int face = Color.rgb(17, 24, 34); + b.fill = new int[]{ + face, // KEY + Color.rgb(18, 27, 40), // DESTINATION + Color.rgb(14, 34, 48), // COMMAND + Color.rgb(38, 20, 18), // WARN + Color.rgb(15, 33, 28), // ENTER + Color.rgb(15, 35, 30), // VOICE + Color.rgb(19, 29, 42), // ARROW + }; + b.edge = new int[]{ + Color.rgb(44, 58, 76), // KEY — unlit; it only types + Color.rgb(60, 96, 130), // DESTINATION + b.accent, // COMMAND — lit: it runs on press + Color.rgb(226, 106, 74), // WARN + Color.rgb(70, 200, 140), // ENTER — lit: it runs on press + Color.rgb(70, 200, 140), // VOICE + Color.rgb(70, 104, 140), // ARROW + }; + b.cornerDp = 5; + b.border = 1; + b.lip = false; + b.engrave = false; + b.glow = true; + b.displays = true; + b.screen = Screen.FRAMED; + // A shade under the deck, no more. This skin has no depth to spend, so the + // screen is told by its outline and by what is written on it. + b.glass = Color.rgb(6, 9, 14); + b.phosphor = b.accent; + return new Skin(b); + } + + /** + * The instrument panel as an object: anodised plate, moulded caps with a + * bevel, and the grain and scratches of something that has been used. + * + *

The colours are warmer and lighter than either of the others, and both + * moves are needed for the material to read. A near-black cap has nowhere to + * put a highlight — a bevel needs a face bright enough that lightening its top + * edge is visible — and a perfectly neutral grey reads as a rendering rather + * than as metal, because anodising and moulded ABS both carry a little warmth. + * + *

The roles keep their hues but arrive at them through the material: a + * destructive key is a red-brown cap of the same plastic, not a red rectangle. + */ + public static Skin machined() { + Builder b = new Builder(Kind.MACHINED); + b.bg = Color.rgb(38, 38, 42); + b.bgPanel = Color.rgb(44, 44, 49); + b.rule = Color.rgb(62, 62, 68); + b.text = Color.rgb(232, 230, 226); + b.muted = Color.rgb(158, 156, 152); + b.heading = b.muted; + b.accent = Color.rgb(96, 148, 196); + b.fill = new int[]{ + Color.rgb(74, 74, 80), // KEY + Color.rgb(70, 78, 92), // DESTINATION + Color.rgb(56, 96, 140), // COMMAND + Color.rgb(134, 68, 44), // WARN + Color.rgb(54, 100, 76), // ENTER + Color.rgb(64, 128, 96), // VOICE + Color.rgb(88, 92, 112), // ARROW + }; + b.edge = b.fill; + b.cornerDp = 3; + b.border = 0; + b.lip = false; // the depth comes from KeyFace instead + b.engrave = true; + b.glow = false; + b.textured = true; + // Three, not one. The lip in CAPPED is a single device pixel and is meant + // to be felt rather than seen; here the shadow has to be wide enough to + // hold a gradient, or the cap has no height to catch light on. + b.depthDp = 3; + b.displays = true; + b.screen = Screen.RECESSED; + // Near black with a trace of green in it, because an unlit phosphor screen + // is never neutral — the coating is visible even with nothing driving it. + b.glass = Color.rgb(9, 13, 10); + // The green of a VFD rather than of an LED: slightly yellow, which is what + // a phosphor actually emits, and it survives being thin far better than a + // pure primary green does. + b.phosphor = Color.rgb(126, 240, 138); + return new Skin(b); + } + + /** + * A spatial control surface: graphite plate, shallow mechanical keys, and the + * reading half as a real embedded terminal rather than as buttons. + * + *

Depth is deliberately small — five to fifteen millimetres of apparent + * relief, not a modelled machine. What the depth is spent on is state: + * a key at rest stands proud, a pressed one travels into its housing, a latched + * one stays down. That is the whole argument for the style — the panel explains + * its own mechanics before anything is read. + * + *

Colour is semantic and sparing. Navigation is cool blue, confirmation is + * green, interruption is red, a suspended or special action is amber, and + * everything else is graphite. Glow marks focus and state, never importance in + * general: a panel where forty keys glow has told you nothing. + */ + public static Skin tactile() { + Builder b = new Builder(Kind.TACTILE); + // Values taken literally from the panel's own token sheet rather than + // re-derived, so the two can be compared without an argument about whether + // the port was faithful. + b.bg = 0xFF0B1017; // --bg + b.bgPanel = 0xFF111824; // --panel + b.rule = 0xFF1B2432; + b.text = 0xFFE7EEF8; // --text + b.muted = 0xFF8391A5; // --text-muted + + b.accentNav = 0xFF2DA8FF; // --blue + b.accentGo = 0xFF36E37B; // --green + b.accentStop = 0xFFFF5252; // --red + b.accentWarn = 0xFFFFAD42; // --amber + b.accent = b.accentNav; + b.heading = 0xFF536072; // --text-dim + + // One material for every key. The variant changes the edge, the legend and + // a tint of the face — never the whole cap. A key repainted end to end + // stops being the same object in a different role and becomes a coloured + // rectangle, which is the flat look this style exists to avoid. + b.fill = new int[]{ + 0xFF202A38, // KEY — --surface-raised, mid stop + 0xFF202A38, // DESTINATION + 0xFF202A38, // COMMAND + 0xFF2B1D22, // WARN — danger keeps a red-brown body + 0xFF1B2C24, // ENTER — success, faintly green + 0xFF1B2C24, // VOICE + 0xFF202A38, // ARROW + }; + // One border colour for the whole panel, warmed a quarter of the way + // towards the role's accent and no further. + // + // The first version used the accent at full strength on danger and success, + // and it read as two loud keys among quiet ones — the eye went to the + // outline rather than to the legend, which is the thing that actually says + // what the key does. A cabinet of real keys does not change the colour of + // its shells per function; it changes the legend, and sometimes the tint of + // the plastic. That is what these are now. + b.edge = new int[]{ + 0xFF334052, // KEY — the neutral border + 0xFF334052, + 0xFF334052, + 0xFF4C3B44, // WARN — neutral, a quarter towards red + 0xFF35473F, // ENTER — neutral, a quarter towards green + 0xFF35473F, + 0xFF3B4A5E, + }; + // The legend carries the role too: pale red on danger, pale green on + // success. Both are lighter than the accent itself — an accent-strength + // letterform on a dark cap is a thin bright shape, and thin bright shapes + // are the first thing the lenses smear. + b.legend = new int[]{ + b.text, b.text, b.text, + 0xFFFFB0B0, // --key--danger colour + 0xFFBAFFCF, // --key--success colour + 0xFFBAFFCF, + b.text, + }; + + // Half of what the token sheet asked for. At 9dp the caps read as soft and + // consumer; at 4 they read as machined parts with a break on the edge, + // which is what the rest of the panel is claiming to be. The screens keep + // their own radius from --radius-lg, so glass and key are still told apart + // by shape as well as by depth. + b.cornerDp = 4; + b.border = 1; + b.lip = false; + b.engrave = false; + b.glow = false; + b.textured = false; + // 0 4px 0 — the solid extrusion that gives the cap its height, plus an + // ambient blur under it. + b.depthDp = 4; + b.travelDp = 3; // translateY(3px) on press + b.displays = true; + b.screen = Screen.RECESSED; + b.listing = true; + b.crt = true; + b.glass = 0xFF031009; // --crt-bg + b.phosphor = 0xFF52FF7D; // --crt-green + b.phosphorDim = 0xFF1B9E47; // --crt-green-dim + b.sections = true; + return new Skin(b); + } + + /** + * The whole device, not only its keys: three layers side by side — the screens + * that are read, the controls that are pressed, and a narrow rail of utilities + * — with the panel drawn as one housing around them. + * + *

This is the first skin whose difference is structural rather than + * material. Every earlier one changed colour, depth and texture while keeping + * the same arrangement, which is why two of them ended up looking alike. Here + * the arrangement itself is the design: a directory listing with a head, mini + * displays reporting state, chips for commands, keys grouped into labelled + * sections, and mechanical controls for the things that latch. + * + *

Its keys stand higher and are rounder than {@code tactile} — 7dp of lift + * against 4, 12dp of radius against 4. That is deliberate contrast, not drift: + * a taller, softer cap belongs to a console you rest your hands on, where a + * tight low one belongs to an instrument face. + */ + public static Skin console() { + Builder b = new Builder(Kind.CONSOLE); + b.bg = 0xFF0B1017; + b.bgPanel = 0xFF111824; + b.rule = 0xFF1B2432; + b.text = 0xFFE7EEF8; + b.muted = 0xFF8391A5; + b.heading = 0xFF7A8CA4; + + b.accentNav = 0xFF2DA8FF; + b.accentGo = 0xFF36E37B; + b.accentStop = 0xFFFF5252; + b.accentWarn = 0xFFFFAD42; + b.accent = b.accentNav; + + // --key-face for this skin: 180deg #2E3A4C → #222C3B → #151D28 + b.fill = new int[]{ + 0xFF222C3B, // KEY + 0xFF222C3B, // DESTINATION + 0xFF222C3B, // COMMAND + 0xFF2B1D22, // WARN + 0xFF15291E, // ENTER — the primary face + 0xFF222C3B, // VOICE — graphite; see the edge below + // The arrow cluster is a shade bluer than the keys around it. It + // is the one group on the pad the hand finds without reading, and + // the eye needs to find it the same way — but it is still a key, + // so the difference is a tint of the same material rather than a + // colour of its own. + 0xFF23334A, // ARROW + }; + b.edge = new int[]{ + 0xFF2B3747, + 0x612DA8FF, // nav keys carry a blue edge at 38% + 0x612DA8FF, + 0x73FF5252, // danger at 45% + 0x9936E37B, // primary at 60% + // The microphone is an input, not a confirmation. It was green + // only because the VOICE role was invented next to ENTER; a + // control that starts dictation has nothing to do with running + // a command, and green now belongs to the things that act. + 0x662DA8FF, // VOICE — blue, with the keyboard + 0x662DA8FF, // ARROW — blue at 40% + }; + b.legend = new int[]{ + b.text, + 0xFFCFE6FF, // --key--nav + 0xFFCFE6FF, + 0xFFFFB0B0, // --key--danger + 0xFFC9FFDC, // --key--primary + 0xFFCFE6FF, // VOICE — the nav legend + 0xFFCFE6FF, // ARROW — the nav legend + }; + + b.cornerDp = 12; // --key-radius for this skin + b.border = 1; + b.lip = false; + b.engrave = false; + b.glow = false; + b.textured = false; + b.depthDp = 7; // --key-lift + b.travelDp = 6; // --key-travel + b.displays = true; + b.screen = Screen.RECESSED; + b.listing = true; + b.crt = true; + b.sections = true; + b.glass = 0xFF031009; + // Green was doing five jobs at once — confirm, data, voice, connection, + // cursor — and the one with the most area wins, so "green means go" could + // not survive next to a whole column of green text. + // + // The fix is saturation, not hue. A saturated green stays the property of + // things that act: Enter, and the lamp that says the link is up. The + // screens drop to a paler, cooler phosphor — which is what a real P1 tube + // looks like anyway, and keeps the reading layer's whole reference intact + // instead of throwing it away for amber. + b.phosphor = 0xFF8FDCA6; + b.phosphorDim = 0xFF4E8F68; + return new Skin(b); + } + + private static final class Builder { + final Kind kind; + int bg, bgPanel, rule, text, muted, heading, accent, cornerDp, border, depthDp; + int glass, phosphor; + int accentNav, accentGo, accentStop, accentWarn, travelDp, phosphorDim; + int[] legend; + boolean listing, crt, sections; + Screen screen = Screen.INSET; + int[] fill, edge; + boolean lip, engrave, glow, textured, displays; + + Builder(Kind kind) { + this.kind = kind; + } + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/TeeHousing.java b/client/app/src/main/java/dev/butschster/linuxterminal/TeeHousing.java new file mode 100644 index 0000000..fa9dd14 --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/TeeHousing.java @@ -0,0 +1,111 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +/** + * The cut-out an arrow cluster sits in: a wide well along the bottom with a + * narrower one rising from the middle of it. The inverted T every keyboard has. + * + *

It exists because the first attempt squared off the bottom corners of the up + * key and closed the gap under it, which is most of the geometry and none of the + * conviction: the three lower keys were inside a housing and the fourth was + * outside it, touching. A key block is a block because the panel was cut + * that shape, so the cut has to include all four. + * + *

Drawn as one path — two rounded rectangles unioned — rather than as two + * overlapping shapes. The difference shows at the join: two shapes drawn one over + * the other leave the seam of the upper one crossing the lower's field, and a + * union has no interior edge at all, which is what a milled recess looks like. + */ +public class TeeHousing extends Drawable { + + private final int fill; + private final float radius; + + /** Which slice of the width the stem occupies: left and right as fractions. */ + private final float stemLeft; + private final float stemRight; + + /** Where the arm begins, as a fraction of the height. */ + private final float armTop; + + private final Paint body = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint wall = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint lip = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final Path shape = new Path(); + private final Path scratch = new Path(); + + public TeeHousing(int fill, float radiusPx, float stemLeft, float stemRight, float armTop) { + this.fill = fill; + this.radius = radiusPx; + this.stemLeft = stemLeft; + this.stemRight = stemRight; + this.armTop = armTop; + body.setColor(fill); + wall.setStyle(Paint.Style.STROKE); + lip.setStyle(Paint.Style.STROKE); + lip.setStrokeWidth(1f); + lip.setColor(0x2EFFFFFF); + } + + @Override + protected void onBoundsChange(Rect bounds) { + float w = bounds.width(); + float h = bounds.height(); + + RectF stem = new RectF(bounds.left + w * stemLeft, bounds.top + 0.5f, + bounds.left + w * stemRight, bounds.top + h * armTop + radius); + RectF arm = new RectF(bounds.left + 0.5f, bounds.top + h * armTop, + bounds.right - 0.5f, bounds.bottom - 0.5f); + + shape.reset(); + shape.addRoundRect(stem, radius, radius, Path.Direction.CW); + scratch.reset(); + scratch.addRoundRect(arm, radius, radius, Path.Direction.CW); + shape.op(scratch, Path.Op.UNION); + + // The wall of the recess: shaded at the top, catching light at the bottom. + // The same description Section uses, so a cut-out is a cut-out wherever it + // appears on the panel. + wall.setShader(new LinearGradient(0, bounds.top, 0, bounds.bottom, + new int[]{0xBF000000, Color.TRANSPARENT, 0x08FFFFFF}, + new float[]{0f, 0.35f, 1f}, Shader.TileMode.CLAMP)); + } + + @Override + public void draw(Canvas canvas) { + canvas.drawPath(shape, body); + + canvas.save(); + canvas.clipPath(shape); + wall.setStrokeWidth(6f); + canvas.drawPath(shape, wall); + canvas.restore(); + + canvas.drawPath(shape, lip); + } + + @Override + public void setAlpha(int alpha) { + } + + @Override + public void setColorFilter(ColorFilter filter) { + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/TermView.java b/client/app/src/main/java/dev/butschster/linuxterminal/TermView.java index cb92aab..0bc00ff 100644 --- a/client/app/src/main/java/dev/butschster/linuxterminal/TermView.java +++ b/client/app/src/main/java/dev/butschster/linuxterminal/TermView.java @@ -61,7 +61,7 @@ public void attach(HostSession session) { } private void rebuildRenderer() { - renderer = new TerminalRenderer(fontSize, Typeface.MONOSPACE); + renderer = new TerminalRenderer(fontSize, Fonts.mono(getContext())); } public int getFontSize() { diff --git a/client/app/src/main/java/dev/butschster/linuxterminal/Wear.java b/client/app/src/main/java/dev/butschster/linuxterminal/Wear.java new file mode 100644 index 0000000..2f1ba01 --- /dev/null +++ b/client/app/src/main/java/dev/butschster/linuxterminal/Wear.java @@ -0,0 +1,112 @@ +package dev.butschster.linuxterminal; + +import android.graphics.Bitmap; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Shader; + +import java.util.Random; + +/** + * The texture of a real object: brushed metal, grain, and the scratches a panel + * picks up from being used. + * + *

Generated rather than shipped. A photographed texture would be an asset to + * license, several hundred kilobytes in the APK, and wrong at any density it was + * not authored for; a tile drawn here is a few kilobytes of pixels made at the + * density it will be drawn at, and it costs one allocation for the life of the + * process. + * + *

Seeded, never random at runtime. {@link Random} with a fixed seed + * gives the same panel every launch. Genuinely random wear would mean a key that + * has a scratch across its legend today and not tomorrow, which is the one way a + * texture can actually cost readability. + * + *

All of it is faint on purpose — grain at roughly 4% and scratches at 6%. + * `docs/readability.md` measured this project's own text going from readable to + * unreadable across a third of a degree of angular size, and every percent of + * contrast spent on texture is a percent not spent on the legend standing on it. + * Texture that is obvious on a monitor is noise through the lenses. + */ +public final class Wear { + + /** One tile, reused everywhere by a repeating shader. */ + private static final int TILE = 192; + + private static Bitmap grain; + private static Bitmap scratches; + + private Wear() {} + + /** + * Fine grain: the sandblasted or moulded surface itself, before any wear. + * + *

Monochrome noise rather than coloured: a coloured grain shifts the hue of + * whatever it lies on, and the palette here is deliberate. Alpha carries it, so + * the same tile works over dark metal and over a lit key alike. + */ + public static synchronized Shader grain() { + if (grain == null) { + grain = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888); + Random random = new Random(0x5EEDL); + int[] pixels = new int[TILE * TILE]; + for (int i = 0; i < pixels.length; i++) { + // Two draws averaged, which is cheap and lands the distribution + // nearer the middle than a single uniform one — pure uniform noise + // reads as static, this reads as a surface. + int value = (random.nextInt(255) + random.nextInt(255)) / 2; + int alpha = 26 + random.nextInt(20); + pixels[i] = Color.argb(alpha, value, value, value); + } + grain.setPixels(pixels, 0, TILE, 0, 0, TILE, TILE); + } + return new BitmapShader(grain, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT); + } + + /** + * Use: brushed lines in one direction, plus the scratches a working panel has. + * + *

Horizontal, because that is what brushing a flat face leaves and because a + * vertical streak on a key would be read as a division of the key. The + * scratches are drawn over the brushing rather than under it, since a scratch + * is later than the finish underneath it. + */ + public static synchronized Shader use() { + if (scratches == null) { + scratches = Bitmap.createBitmap(TILE, TILE, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(scratches); + canvas.drawColor(Color.TRANSPARENT); + Random random = new Random(0xB4005AL); + + // The brushed finish: many faint full-width lines, alternately lighter + // and darker, so the surface has direction without having a pattern. + Paint brush = new Paint(Paint.ANTI_ALIAS_FLAG); + brush.setStrokeWidth(1f); + for (int i = 0; i < TILE * 2; i++) { + float y = random.nextFloat() * TILE; + boolean light = random.nextBoolean(); + int alpha = 6 + random.nextInt(10); + brush.setColor(light ? Color.argb(alpha, 255, 255, 255) + : Color.argb(alpha, 0, 0, 0)); + canvas.drawLine(0, y, TILE, y, brush); + } + + // Scratches: short, near-horizontal, and light, because a scratch in a + // dark anodised surface exposes the brighter metal under it. + Paint scratch = new Paint(Paint.ANTI_ALIAS_FLAG); + scratch.setStrokeCap(Paint.Cap.ROUND); + for (int i = 0; i < 26; i++) { + float x = random.nextFloat() * TILE; + float y = random.nextFloat() * TILE; + float length = 8 + random.nextFloat() * 60; + float drop = (random.nextFloat() - 0.5f) * 7; + scratch.setStrokeWidth(0.6f + random.nextFloat() * 0.8f); + scratch.setColor(Color.argb(18 + random.nextInt(26), 255, 255, 255)); + canvas.drawLine(x, y, x + length, y + drop, scratch); + } + } + return new BitmapShader(scratches, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT); + } +} diff --git a/docs/panel-review.md b/docs/panel-review.md new file mode 100644 index 0000000..2284d7b --- /dev/null +++ b/docs/panel-review.md @@ -0,0 +1,187 @@ +# Review of the console panel + +Four reviews of the `console` concept layout, taken in November against the +running build: a control-surface designer on **grouping**, a designer of dense +professional interfaces on **visual hierarchy**, a spatial-interaction designer +on **ray ergonomics**, and an interaction designer on **information +architecture**. + +Every claim below that is marked *settled* was checked against the code or the +running panel before it was written down. Everything else is marked as a +derivation and says what would settle it. + +The screenshots the reviews were taken against are the `console` skin at a shell +prompt and with Claude Code in front. + +## Already fixed + +**The keypad was inside a scroller with the shortcuts.** The rule everyone +checks — `KeyPad` is built once and context never touches it — was being +honoured, and the keys still moved: with enough shortcuts to overflow the +column, scrolling to reach a chord carried Enter and `^C` off the panel. The +guarantee is about where the keys *are*, not how often they are constructed. +The keypad is now outside the scroller; only the shortcuts scroll. + +**The bar offered `cd` while a program owned the terminal.** `classify` returns +an empty kind for anything not in its table — `make`, `pytest`, `docker compose +up`, `journalctl -f` — and `pushContext` left `tool` nil, which sent the bar +down the at-a-prompt branch. Pressing a directory typed `cd 'server'` and a +newline into that program's stdin. The intent was already written in the comment +above `fallbackTool`; the branch was not. Now: foreground process group is not +the shell's, therefore the shell is not reading, therefore no action that +assumes a prompt is offered. Server-side, no APK. + +## Defects confirmed by reading the code + +These are not opinions. Each was verified. + +| | | +|---|---| +| `Buttons.dangerGap()` has **zero call sites** | The double gap beside a destructive control is documented, implemented, and used nowhere. `^D` (closes the shell) sits 8dp from `^C` (pressed constantly); both are red, so colour discriminates nothing. | +| `Rotary` is never instantiated; `Lever` is reachable only from `leverCell()` and `railLever()`, **neither of which is called** | Both are dead code. The rail replaced them with keys and rockers. | +| Every `Led` is constructed lit and `setOn` is never called | A lamp that is always on trains the eye to stop reading it. | +| Chip text is 12.5dp | `docs/readability.md` puts the unreadable line at 16px ≈ 12.5dp. Section headings (9dp), mini notes (10dp) and row marks (11dp) are below it. | +| `BarActivity` declares 1150×460dp | The console layout needs roughly 840dp of height. Window size is the one property Horizon OS lets the app set, and it is set for the old layout. | +| `debounced()` keeps its timestamp **per view**, and `fillConsole` rebuilds every view on `cd` | A trigger bounce after a directory press lands on a freshly built view with a zero debounce, under a ray that has not moved. The guard is defeated by the rebuild it causes. | +| `runActions` adds `git status` / `git diff` only when `Git.Dirty > 0` | Editing a file in another window inserts two chips into a row the user never touched. | +| The server distinguishes `skill` from `skill-global`; the client draws them in one row | The distinction survives only as a colour, the weakest channel through these lenses. | +| `root` is computed, sent, and never drawn | The only context field the client ignores. In a deep subdirectory nothing says which project the path belongs to. | + +## Where the reviews agree + +**Spacing carries safety, not colour.** Through pancake lenses a mark beats a +tint. Everything that fires on press should carry the Enter mark, not only the +`warn` ones; everything irreversible — `/clear`, `:q!`, `^D` — needs a moat of +20dp and, on the worst of them, a hold to fire. + +**Sub-threshold text is texture, not text.** Headings at 9dp are the least +readable thing on a surface whose regions they are supposed to name. Raise +anything meant to be read to 12dp minimum; chips to 14dp. + +**The rocker earns its keep; the lever and the knob do not.** A rocker is +pressed, which is the verb ray input actually has, and its two halves cannot +drift apart. A lever's whole visual argument is "throw me" and it is operated by +pointing and pulling a trigger — it teaches a gesture that does not exist. A +knob promises a continuous range for a value that is stepped, and its real +feedback is detents felt in the hand, which a ray cannot feel. + +**Mode may move the panel; data may not.** A section can disappear because the +foreground program changed — the user caused that and the header confirms it. A +section must not appear because a repository went dirty. Mode is a fact about +your hands; data is weather. + +**State is the panel's weak half.** It says a great deal about where you are and +almost nothing about what is happening. The two readouts it does have echo the +user's own presses rather than reporting the machine. + +## Where the reviews disagree — decisions for the owner + +**1. Section plates: differentiate or delete.** +*Grouping* wants fixed clusters and dynamic clusters to carry different plate +finishes, so the eye learns which housings are furniture and which are weather. +*Hierarchy* wants the plates deleted wherever the content is already a screen — +the glass is the container, and a box around a box at nearly the same radius +reads as a framing error. Both note the plate is currently 2% brighter than the +panel, which through the lenses is the same colour, so today it is paid for +entirely by its outline. + +**2. The phosphor's colour.** +*Hierarchy* argues green now means five things at once — confirm, data, voice, +connection, cursor — so "green = go" cannot survive, and wants the screens moved +to amber. Against it: `capped` already uses amber for exactly this reason, and +moving `console` there costs the CRT reference that the whole reading layer is +built on. + +**3. Column order.** +*Ergonomics* observes that precision demand and reach are inversely matched: the +smallest targets (listing rows) sit in the left column, the longest reach for a +right hand, while the rail — high frequency, low precision — is correctly at the +cheap edge. It prefers fixing sizes over reordering. *Architecture* wants +`COMMANDS` moved to the top of the reading column, so that hiding the browser +and the projects does not lift the one section that survives both modes. + +## Sizes are settled, and not by arithmetic + +**The panel is worn close.** It is pulled in until it sits where a console sits +when you are sitting at one — much nearer than the 1.5 m the readability figures +were measured at. Read at that distance, every size on the panel is comfortable, +including the ones the reviews flagged as sub-threshold. + +That single fact retires most of the sizing section below. Angular size is what +readability follows, and angular size is distance divided into height: a panel +brought to half the distance doubles every glyph without a single number +changing in the code. The reviews were right about the arithmetic and were +reasoning from a distance nobody uses. + +So: **no size changes, and no dp-per-degree measurement.** The numbers below are +kept because they are correct *if the panel is ever pushed out* — if a future +layout is too wide to keep close, or somebody works with it across the room, +this is the analysis that applies. Until then it is not a defect list. + +What survives from it regardless of distance is everything about **spacing and +consequence** rather than size: a moat beside a control that closes the shell is +about the cost of a miss, and that cost does not shrink when the panel comes +closer. + +## What the reviews assumed about distance + +The reviews converge on one measurement that everything else depends on: +**dp per degree for this panel at its own default distance.** + +`docs/readability.md` measured 51.2 px/degree on a 1.5 m cylindrical layer, not +on a native Horizon window. Dividing by the manifest's ~1.25 px/dp gives roughly +**40dp per degree**, and from that: a 46×40dp key is 1.15°×1.00°, a listing row +pitch is 0.68°, chip text is 0.31°. + +That conversion also reconciles the old argument. If "1° of ray error" is the +full spread rather than one standard deviation, a 1.0° target is 2σ — correct +for a harmless key. **The wearer was right and the arithmetic was wrong**, and +40dp becomes a validated floor rather than a compromise. + +Until the number is measured on this window, every size recommendation here is +scaled from an assumption. Measure it first. + +Also for the headset, in order: whether the 1dp key border survives at all (it +is ~0.02°, an order of magnitude under the smear floor — if it does not survive, +the entire role-coding budget is being spent on something invisible); whether +the paging rocker and the scrolling rocker are confusable when they differ only +by one chevron against two; whether any section plate can be made perceptible +while staying dark; and whether phosphor green and Enter green actually confuse. + +## Protocol additions the grouping needs + +Keep `Group{name, items[]}` and give each item a `kind`. Fewer kinds is better: + +- **`key`** — as today. +- **`rocker`** — `{up, down, repeat, axis}`. A stepper is a rocker with a + different glyph pair, not a separate kind. +- **`segmented`** — `{label, options[]}`, momentary. **No `selected` field, by + design**: the server cannot read which answer a program is waiting for, and a + control that shows unreadable state is forbidden. +- **`cluster`** — `{name, items[]}`, a named housing inside a group. This is + what restores headings to the keystroke side, which the client currently + discards when it hoists keystrokes out of every group into one flow. +- **`place`** — `{label, send, rank}` where rank is parent / child / jump. +- **`readout`** — `{label, value, lamp}`, so the server owns what the lamps say. +- **`isolate: true`** — an item-level flag asking for `dangerGap()` around this + control. One boolean beats a spacer widget. + +`⇧Tab` must stay a plain key. It cycles a permission mode that lives inside +Claude, where nothing can read it; drawing it as a selector would promise a +state nobody can honour. This is the most tempting wrong move on the panel. + +## Order of work + +Sizes and the dp-per-degree measurement are **out of scope** — see above; the +panel is worn close and reads comfortably as it is. The visual language is what +is being worked on, so: + +1. The two decisions on plates and phosphor, and the visual hierarchy work that + follows from them. +2. Spacing where a miss is expensive — `dangerGap()` around anything that closes + a shell or wipes a conversation. Distance does not make a mis-hit cheaper. +3. Delete what is dead: `Rotary`, `Lever`, and the two methods that are the only + things reaching them. +4. The confirmed behavioural defects: lamps that never change, the debounce + defeated by the rebuild, chips that appear because a repository went dirty. +5. The protocol additions, once the grouping they serve is settled. diff --git a/docs/service.md b/docs/service.md new file mode 100644 index 0000000..113f2ee --- /dev/null +++ b/docs/service.md @@ -0,0 +1,77 @@ +# The server as a user service, and what that costs + +`make install` puts the server behind `systemd --user`, which is the right place +for it: the shells it opens are yours, they inherit your environment, and nothing +about it wants root. The cost is that a service's environment is **not** a +terminal's environment, and two things that worked when the server was started by +hand quietly stopped working when it was installed. + +Both were found the same way, and it is worth doing that way again: + +```sh +tr '\0' '\n' < /proc/$(pgrep -f linux-terminal-server | head -1)/environ +systemctl --user show-environment +``` + +The first prints what the running server actually has. The second prints what +`systemd --user` has **now**. When those two disagree, something started before +the desktop did. + +## The service's PATH is not the shell's PATH + +A service enabled with `WantedBy=default.target` starts at login with the bare +system PATH — `/usr/local/bin`, `/usr/bin`, and so on. Nothing in `~/.bashrc` has +run, so `~/.local/bin` is not on it. + +That is where the interesting tools live. `claude`, `codex`, and this machine's +own `cch` and `cgit` are all installed there, and `runActions` in +`server/actions.go` offers a button only for a command that exists — a button +that answers with `command not found` is worse than no button. + +So the bar dropped four of its six commands, and only when the server was +installed properly. Started from a terminal for a day's work, the same binary +offered all of them, because a terminal's PATH is the one `.bashrc` built. + +The fix is to ask the shell rather than to ask this process: + +``` +$SHELL -ic 'printf "\n%s\n" "$PATH"' +``` + +Interactive and **not** a login shell, because that is exactly what `pty.Start` +opens for a session. On this machine the difference is real: `-lc` reads +`.profile` and returns a PATH without `~/.nvm` and half the rest; `-ic` reads +`.bashrc` and returns what a session will actually search. It is read once and +cached, because it forks a shell. + +## The tray icon needs a bus, not a display + +`trayAvailable` used to require `DISPLAY` or `WAYLAND_DISPLAY`. Neither is in a +user service's environment at the moment it starts, and neither ever appears in +it afterwards — the graphical session imports them into `systemd --user` after +the service is already running, which is why `systemctl --user show-environment` +lists `WAYLAND_DISPLAY` while `/proc//environ` of the server does not. + +The result was a server that had been running for three days on a desktop machine +and had never once tried to show an icon. + +What the tray actually needs is somebody willing to hold one, and that is a name +on the session bus: + +```sh +busctl --user list | grep StatusNotifierWatcher +``` + +So the server now waits for `org.kde.StatusNotifierWatcher` to be owned and only +then hands the goroutine to systray. Waiting is free — that goroutine would be +blocked inside the library anyway — and it means the icon appears when you log in +rather than never. `fyne.io/systray` already re-registers by itself if the +desktop restarts later; what it cannot do is start on a desktop we refused to +look for. + +To see whether the icon is registered, and which process owns it: + +```sh +busctl --user list | grep StatusNotifierItem +busctl --user status :1.1458 | grep -E '^(PID|Comm)=' +``` diff --git a/server/actions.go b/server/actions.go index 55a73fa..3b0f7e7 100644 --- a/server/actions.go +++ b/server/actions.go @@ -1,10 +1,15 @@ package main import ( + "context" "fmt" + "log" + "os" "os/exec" + "path/filepath" "strings" "sync" + "time" ) // Action is one button. The client draws label, colours it by style, and on a press @@ -172,11 +177,73 @@ func installed(name string) bool { if cached, ok := installedCache.Load(name); ok { return cached.(bool) } - _, err := exec.LookPath(name) - installedCache.Store(name, err == nil) - return err == nil + found := false + for _, dir := range sessionPath() { + if dir == "" { + continue + } + // Stat, not LookPath: the search list is the shell's, not this process's, + // and LookPath can only be told about the second. Following the symlink is + // wanted — every one of these tools installs itself as one. + info, err := os.Stat(filepath.Join(dir, name)) + if err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { + found = true + break + } + } + installedCache.Store(name, found) + return found } +// sessionShell is the shell sessions are opened with. Set once at start-up, +// because the button tables have to ask the same shell the session will get. +var sessionShell = "/bin/bash" + +// sessionPath is the PATH a shell opened here would search, which is emphatically +// not the PATH this process has. +// +// This was `exec.LookPath`, and it was wrong in the one place it matters. The +// server usually runs as a `systemd --user` service, whose PATH is the bare +// system one — /usr/bin and friends. Every tool this machine's owner actually +// installed lives in ~/.local/bin, which is put on the PATH by their .bashrc. +// So `claude`, `codex` and the rest were plainly there, plainly runnable in the +// session the button would have typed into, and the bar refused to offer them +// because the *service* could not see them. Started from a terminal instead, +// the same server offered all of them — which is why this survived so long. +// +// Asked the way a session is opened: interactive, not a login shell. That is what +// pty.Start gives you, and on this machine it is the difference between a PATH +// with ~/.local/bin on it and one without. +// +// Read once. A shell is forked for it, so it must not happen per refresh — and a +// tool installed while the server runs still needs a restart to appear, which is +// the same trade the cache above already made. +var sessionPath = sync.OnceValue(func() []string { + own := filepath.SplitList(os.Getenv("PATH")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // The leading newline is deliberate: an interactive shell may greet, and the + // answer is whatever the last line is. + cmd := exec.CommandContext(ctx, sessionShell, "-ic", `printf '\n%s\n' "$PATH"`) + cmd.Env = shellEnv() + out, err := cmd.Output() + if err != nil { + log.Printf("cannot read the shell's PATH (%v) — offering only what this process can see", err) + return own + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + answer := strings.TrimSpace(lines[len(lines)-1]) + if !strings.Contains(answer, string(filepath.ListSeparator)) { + // Not a PATH. A shell whose printf takes a list rather than a string — + // fish — lands here, and the process's own PATH is a better answer than + // a fragment of one. + return own + } + return filepath.SplitList(answer) +}) + func trim(text string, limit int) string { if len(text) <= limit { return text diff --git a/server/context.go b/server/context.go index 10e6341..05031f7 100644 --- a/server/context.go +++ b/server/context.go @@ -77,6 +77,20 @@ func (s *Session) pushContext(force bool) { isShell = true } else if kind != "" { tool = &kind + } else { + // Unrecognised, but still not the shell: make, pytest, docker compose, + // journalctl -f. The bar used to leave tool nil here, which sent it + // down the "at a prompt" branch and offered directories to cd into — + // while a program owned the terminal. Pressing one typed cd 'server' + // and a newline into that program's stdin. + // + // What is known is read rather than guessed: the foreground process + // group is not the shell's, so the shell is not reading. That alone + // rules out every action that assumes a prompt. The fallback table is + // what is offered instead, and the name stays the binary's own so the + // header still says what is running. + fallback := fallbackTool + tool = &fallback } } diff --git a/server/go.mod b/server/go.mod index a94bff1..b70172f 100644 --- a/server/go.mod +++ b/server/go.mod @@ -5,10 +5,8 @@ go 1.26.5 require ( fyne.io/systray v1.12.2 github.com/creack/pty v1.1.24 + github.com/godbus/dbus/v5 v5.1.0 + gopkg.in/yaml.v3 v3.0.1 ) -require ( - github.com/godbus/dbus/v5 v5.1.0 // indirect - golang.org/x/sys v0.15.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) +require golang.org/x/sys v0.15.0 // indirect diff --git a/server/main.go b/server/main.go index bcdff45..0af6de9 100644 --- a/server/main.go +++ b/server/main.go @@ -130,6 +130,9 @@ func main() { if shell == "" { shell = "/bin/bash" } + // The button tables ask this shell what a session's PATH would be; see + // sessionPath in actions.go. + sessionShell = shell name := *flagName if name == "" { @@ -202,13 +205,25 @@ func main() { // systray owns the goroutine it is given and never returns it, so the tray // takes the main one and accepting moves aside. - if *flagTray && trayAvailable() { + // + // Waiting for the desktop happens on this side of the fence rather than + // inside the library: a user service starts at login, before anything is + // offering to show an icon, and the wait is what makes the icon appear a few + // seconds later instead of never. Sessions are served throughout — accepting + // has already moved to its own goroutine by then. + if *flagTray && trayPossible() { go accept() - server.runTray() - return + if awaitTrayHost() { + server.runTray() + return + } + // The bus is unusable, so there will be no icon — but the shells this + // process is serving are not the tray's to end. + log.Printf("running without a tray icon") + select {} } if *flagTray { - log.Printf("no desktop session here — running without a tray icon") + log.Printf("no session bus here — running without a tray icon") } accept() } diff --git a/server/tray.go b/server/tray.go index c39643f..4ad8726 100644 --- a/server/tray.go +++ b/server/tray.go @@ -11,6 +11,7 @@ import ( "time" "fyne.io/systray" + "github.com/godbus/dbus/v5" ) // An indicator in the desktop's tray, for when the server runs on a machine you @@ -66,11 +67,97 @@ func (s *Server) trayStatus() trayState { return state } -// trayAvailable reports whether there is a desktop to put an icon on. Without -// this the library waits for a bus that will never answer. -func trayAvailable() bool { - return os.Getenv("DBUS_SESSION_BUS_ADDRESS") != "" && - (os.Getenv("WAYLAND_DISPLAY") != "" || os.Getenv("DISPLAY") != "") +// The name a desktop takes on the session bus when it is willing to host tray +// icons. GNOME's AppIndicator extension owns it; so does KDE, and so does any +// other implementation of the same specification. +const trayWatcher = "org.kde.StatusNotifierWatcher" + +// trayPossible reports whether there is a session bus at all — which is the one +// thing that cannot appear later. Without a bus the library would wait for an +// answer that will never come. +func trayPossible() bool { + return os.Getenv("DBUS_SESSION_BUS_ADDRESS") != "" +} + +// awaitTrayHost blocks until a desktop is willing to hold an icon, and reports +// false only if the bus itself cannot be used. +// +// This replaced a check for DISPLAY or WAYLAND_DISPLAY, and the difference is +// the whole bug. A user service is started by `systemd --user` at login, which +// is BEFORE the graphical session has imported those variables into it — so the +// server's own environment has neither, forever, no matter how many screens are +// plugged in. `systemctl --user show-environment` on this machine lists +// WAYLAND_DISPLAY while /proc//environ of a server started three days ago +// does not, which is exactly that ordering. The old check therefore answered +// "no desktop here" on a desktop machine and the icon was never even attempted. +// +// Asking the bus is both correct and later-proof: the question is not whether an +// X server exists, it is whether anything is offering to show an icon, and that +// is a name on the bus. Waiting for it costs nothing — this runs on a goroutine +// that would otherwise be blocked inside systray anyway — and it means the icon +// appears at login on a machine where the server was started before there was a +// session, which is the ordinary case for a service enabled with `WantedBy= +// default.target`. +func awaitTrayHost() bool { + conn, err := dbus.SessionBusPrivate() + if err != nil { + log.Printf("tray: no session bus: %v", err) + return false + } + // Private, not shared: systray opens its own connection and closing a shared + // one under it would take the icon with it. + defer conn.Close() + if err := conn.Auth(nil); err != nil { + log.Printf("tray: session bus refused us: %v", err) + return false + } + if err := conn.Hello(); err != nil { + log.Printf("tray: session bus refused us: %v", err) + return false + } + + if trayHosted(conn) { + return true + } + + signals := make(chan *dbus.Signal, 8) + conn.Signal(signals) + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath("/org/freedesktop/DBus"), + dbus.WithMatchInterface("org.freedesktop.DBus"), + dbus.WithMatchSender("org.freedesktop.DBus"), + dbus.WithMatchMember("NameOwnerChanged"), + dbus.WithMatchArg(0, trayWatcher), + ); err != nil { + log.Printf("tray: cannot watch for a desktop: %v", err) + return false + } + + // Asked again after subscribing, because a desktop that arrived between the + // first question and the match rule would otherwise never be noticed. + if trayHosted(conn) { + return true + } + + log.Printf("tray: waiting for a desktop to offer a tray") + for signal := range signals { + // NameOwnerChanged carries [name, old owner, new owner]; a non-empty new + // owner is somebody taking the name. + if len(signal.Body) < 3 { + continue + } + if owner, ok := signal.Body[2].(string); ok && owner != "" { + return true + } + } + return false +} + +func trayHosted(conn *dbus.Conn) bool { + var owned bool + err := conn.BusObject().Call("org.freedesktop.DBus.NameHasOwner", 0, trayWatcher). + Store(&owned) + return err == nil && owned } // runTray blocks — systray owns the goroutine it is given.