{
  "id": "odometer",
  "kind": "recipe",
  "tags": [
    "device"
  ],
  "title": "Reading an odometer (OCR)",
  "note": "Show a strip, read the strip. The viewfinder is only as tall as the digits, and the recognizer gets that band and nothing else — so the speedometer and the trip meter around it never compete with the number.",
  "className": "R34Odometer",
  "package": "dev.cf.recipe",
  "doc": "<p>Reading an odometer with the camera: a live strip, one tap, a number.</p>\n\n<p><b>The viewfinder is a strip, not a photo.</b> It is about as tall as the row of digits on a\ndashboard, and that is the whole trick: the person lines the odometer up inside it, and the\nrecognizer is handed <b>exactly that band</b> of the frame &mdash; not the speedometer, the\ntrip meter, the clock or the outside temperature around it, every one of which is a number too.\n<code>band</code> is the one rectangle both sides use: the strip paints it, the read crops it, so what\nis read is what was seen.</p>\n\n<p>The camera here is <code>videoPreview</code>, not a picker. A picker is a native\nwindow over the whole screen; a preview is frames, and frames are an <code>Image</code> like any other,\npainted by the strip's own <code>onPaint</code> &mdash; so it scrolls, clips and sits in a page. It is\nstarted by a tap and stopped when the screen goes away: a camera that turns on because a screen\nopened is a light coming on that nobody asked for.</p>\n\n<p><code>TextRecognition</code> returns lines of text; the number is this recipe's business. Dials\nprint leading zeros, recognizers split a long run with a space and read a 0 as an O, and\n<code>odometer</code> undoes exactly those three, then keeps the longest run of digits.</p>",
  "api": [
    {
      "name": "Button",
      "doc": "/docs/ui/widgets/button"
    },
    {
      "name": "Capabilities",
      "doc": null
    },
    {
      "name": "Capability",
      "doc": null
    },
    {
      "name": "Component",
      "doc": null
    },
    {
      "name": "Debug",
      "doc": null
    },
    {
      "name": "Device",
      "doc": null
    },
    {
      "name": "Image",
      "doc": null
    },
    {
      "name": "Label",
      "doc": "/docs/ui/widgets/label"
    },
    {
      "name": "Main",
      "doc": null
    },
    {
      "name": "Paint",
      "doc": null
    },
    {
      "name": "PlatformUtils",
      "doc": null
    },
    {
      "name": "Relative",
      "doc": null
    },
    {
      "name": "Screen",
      "doc": null
    },
    {
      "name": "Styles",
      "doc": null
    },
    {
      "name": "TextRecognition",
      "doc": null
    },
    {
      "name": "VerticalLayout",
      "doc": null
    },
    {
      "name": "Widgets",
      "doc": null
    }
  ],
  "files": [
    {
      "path": "dev/cf/recipe/R34Odometer.java",
      "name": "R34Odometer.java",
      "code": "package dev.cf.recipe;\n\nimport java.io.ByteArrayOutputStream;\n\nimport dev.cf.Tones;\n\nimport dev.crossfly.Capability;\nimport dev.crossfly.Main;\nimport dev.crossfly.PlatformUtils;\nimport dev.crossfly.capability.Capabilities;\nimport dev.crossfly.capability.TextRecognition;\nimport dev.crossfly.device.Device;\nimport dev.crossfly.ui.*;\nimport dev.crossfly.ui.graphics.*;\nimport dev.crossfly.ui.image.*;\nimport dev.crossfly.ui.layout.*;\nimport dev.crossfly.ui.style.*;\nimport dev.crossfly.util.Debug;\n\nimport static dev.crossfly.ui.Widgets.*;\n\n/**\n * Reading an odometer with the camera: a live strip, one tap, a number.\n *\n * <p><b>The viewfinder is a strip, not a photo.</b> It is about as tall as the row of digits on a\n * dashboard, and that is the whole trick: the person lines the odometer up inside it, and the\n * recognizer is handed <b>exactly that band</b> of the frame &mdash; not the speedometer, the\n * trip meter, the clock or the outside temperature around it, every one of which is a number too.\n * {@link #band} is the one rectangle both sides use: the strip paints it, the read crops it, so what\n * is read is what was seen.</p>\n *\n * <p>The camera here is {@link PlatformUtils#videoPreview}, not a picker. A picker is a native\n * window over the whole screen; a preview is frames, and frames are an {@link Image} like any other,\n * painted by the strip's own {@code onPaint} &mdash; so it scrolls, clips and sits in a page. It is\n * started by a tap and stopped when the screen goes away: a camera that turns on because a screen\n * opened is a light coming on that nobody asked for.</p>\n *\n * <p>{@link TextRecognition} returns lines of text; the number is this recipe's business. Dials\n * print leading zeros, recognizers split a long run with a space and read a 0 as an O, and\n * {@link #odometer} undoes exactly those three, then keeps the longest run of digits.</p>\n */\npublic class R34Odometer extends Recipe\n{\n   /** The strip's height, in font heights: one row of dashboard digits and a little margin. */\n   private static final float STRIP = 3.5f;\n\n   /** Below this height the band is enlarged before it is read: recognizers want tall glyphs. */\n   private static final int MIN_OCR_HEIGHT = 160;\n\n   private Viewfinder strip;\n   private Button go;\n   private Label value, status;\n\n   /** Off, live, reading, or showing what was read over the frozen frame. */\n   private enum State { OFF, LIVE, READING, DONE }\n\n   private State state = State.OFF;\n\n   public String title()\n   {\n      return \"Reading an odometer (OCR)\";\n   }\n\n   public String note()\n   {\n      return \"Show a strip, read the strip. The viewfinder is only as tall as the digits, and the\"\n         + \" recognizer gets that band and nothing else — so the speedometer and the trip meter\"\n         + \" around it never compete with the number.\";\n   }\n\n   public void build(Component body) throws Exception\n   {\n      strip = new Viewfinder();\n      strip.getStyle()\n         .backColor(0xFF000000)\n         .borderRadius(Styles.borderRadius)\n         .autoHeight(Relative.ofFont(STRIP));\n      strip.testTag(\"odometer.strip\");\n      body.add(LAY_AUTO, outgap(strip, 0, 0, 0, .8f));\n\n      go = Tones.action(\"Open camera\");\n      go.testTag(\"odometer.go\");\n      go.onAction(e -> tap());\n      body.add(LAY_AUTO, outgap(go, 0, 0, 0, 1.2f));\n\n      // The reading is pinned below the scrolling body: with the strip and the button above it, it\n      // sat under the fold and the person had to scroll to see what was read (14/10/2026).\n      Component result = new Component();\n      result.setLayout(new VerticalLayout(Position.START, Position.START));\n      result.add(LAY_AUTO, label(\"ODOMETER\", 1f, true, Styles.captionColor, Position.START));\n      value = label(\"—\", 3f, true, Styles.textColor, Position.START);\n      value.testTag(\"odometer.value\");\n      result.add(LAY_AUTO, value);\n\n      status = label(\"\", 1f, false, Styles.captionColor, Position.START);\n      status.testTag(\"odometer.status\");\n      result.add(LAY_AUTO, outgap(status, 0, .5f, 0, 0));\n      pin(result);\n\n      // Ask both before offering either. The camera alone is a viewfinder that reads nothing; the\n      // recognizer alone has nothing to read.\n      boolean cam = PlatformUtils.isSupported(Capability.VIDEO_PREVIEW);\n      boolean ocr = PlatformUtils.isSupported(Capability.TEXT_RECOGNITION);\n      if (!cam || !ocr)\n      {\n         go.setEnabled(false);\n         // On an iPhone the recognizer is Apple's Vision, from iOS 13: say so, or \"this device\" reads\n         // like a broken one (an iPhone 5s on iOS 12, 17/10/2026).\n         say(!cam ? \"No camera preview on this device.\"\n            : \"No text recognizer on this device.\" + (Device.platform != null && Device.platform.isIOS()\n               ? \" On iOS it needs iOS 13 or later.\" : \"\"));\n      }\n      else\n         say(\"Point the camera at the odometer and fit the digits inside the strip.\");\n   }\n\n   /** The one button walks the states: open the camera, read, and read again. */\n   private void tap()\n   {\n      if (state == State.LIVE)\n         read();\n      else if (state != State.READING)\n         start();\n   }\n\n   private void start()\n   {\n      strip.frame = null;\n      try\n      {\n         // Ask for more pixels than the strip shows: the band is a slice of the frame, and the\n         // recognizer reads that slice, not the screen.\n         if (!PlatformUtils.instance.videoPreview(1280, 720, strip::onFrame))\n         {\n            say(\"The camera did not open.\");\n            return;\n         }\n      }\n      catch (Exception e)\n      {\n         Debug.handle(e);\n         say(\"The camera did not open: \" + e.getMessage());\n         return;\n      }\n      setState(State.LIVE, \"Read\");\n      say(\"Fit the digits inside the strip, then tap Read.\");\n   }\n\n   private void stop()\n   {\n      try\n      {\n         PlatformUtils.instance.videoPreview(0, 0, null);\n      }\n      catch (Exception e)\n      {\n         Debug.handle(e);\n      }\n   }\n\n   private void read()\n   {\n      Image frame = strip.frame;\n      if (frame == null)\n      {\n         say(\"No picture from the camera yet.\");\n         return;\n      }\n      // Stop first: the frame stays on the strip, frozen, and no camera thread writes into it\n      // while it is being cut.\n      stop();\n      byte[] png;\n      try\n      {\n         png = cut(frame, strip.getWidth(), strip.getHeight());\n      }\n      catch (Exception e)\n      {\n         Debug.handle(e);\n         say(\"Could not cut the picture: \" + e.getMessage());\n         setState(State.DONE, \"Try again\");\n         return;\n      }\n      setState(State.READING, \"Reading…\");\n      say(\"Reading…\");\n      // -Dcrossfly.ocr.debug=1 keeps the exact PNG the recognizer gets: when nothing is read, the\n      // picture is what tells a bad cut or a blurred frame from a recognizer that cannot read.\n      if (\"1\".equals(System.getProperty(\"crossfly.ocr.debug\")))\n         try (java.io.FileOutputStream o = new java.io.FileOutputStream(\"ocr-\" + System.currentTimeMillis() + \".png\"))\n         {\n            o.write(png);\n         }\n         catch (Exception e)\n         {\n            Debug.log(\"[odometer] debug dump failed: \" + e);\n         }\n      Capabilities.textRecognition().recognize(png, (result, error) ->\n      {\n         // Arrives on the paint thread, so the labels can be touched directly.\n         setState(State.DONE, \"Read again\");\n         if (error != null)\n         {\n            show(\"—\");\n            say(\"The recognizer could not read the picture: \" + error);\n            return;\n         }\n         String n = odometer(result.text);\n         show(n == null ? \"—\" : n);\n         say(n == null\n            ? (result.text.isEmpty() ? \"No text in the strip.\" : \"No number in: \" + result.text.replace('\\n', ' '))\n            : \"Read: \" + result.text.replace('\\n', ' '));\n      });\n   }\n\n   /**\n    * The rectangle of a frame that a viewfinder of the given size shows, centred, cut to fill\n    * (\"cover\"): the strip paints this and the read crops this, so the two cannot disagree.\n    *\n    * @param imgW  the frame's width\n    * @param imgH  the frame's height\n    * @param viewW the viewfinder's width\n    * @param viewH the viewfinder's height\n    * @return {x, y, w, h} in the frame's pixels\n    */\n   public static int[] band(int imgW, int imgH, int viewW, int viewH)\n   {\n      float scale = Math.max(viewW / (float) imgW, viewH / (float) imgH);\n      int w = Math.min(imgW, Math.max(1, Math.round(viewW / scale)));\n      int h = Math.min(imgH, Math.max(1, Math.round(viewH / scale)));\n      return new int[]{(imgW - w) / 2, (imgH - h) / 2, w, h};\n   }\n\n   /**\n    * Cuts the band a viewfinder shows out of a frame and encodes it for the recognizer, enlarged\n    * when it is short &mdash; a 60-pixel strip of digits is legible to a person and too small for\n    * most recognizers.\n    *\n    * @param frame the camera frame\n    * @param viewW the viewfinder's width\n    * @param viewH the viewfinder's height\n    * @return a PNG of the band\n    * @throws Exception when the image cannot be cut or encoded\n    */\n   public static byte[] cut(Image frame, int viewW, int viewH) throws Exception\n   {\n      int[] b = band(frame.getWidth(), frame.getHeight(), viewW, viewH);\n      Image area = frame.getArea(b[0], b[1], b[2], b[3]);\n      Image big = b[3] < MIN_OCR_HEIGHT ? area.resizeHeight(MIN_OCR_HEIGHT) : area;\n      try\n      {\n         ByteArrayOutputStream out = new ByteArrayOutputStream();\n         big.toPng(out);\n         return out.toByteArray();\n      }\n      finally\n      {\n         if (big != area)\n            big.dispose();\n         area.dispose();\n      }\n   }\n\n   /**\n    * The odometer's number in what the recognizer read: the longest run of digits.\n    * <p>\n    * A space, a dot or a comma with one to three digits before it and exactly three after is a\n    * thousands gap (\"48 213\", \"48.213\"), not the end of the number; anything else is a second\n    * number, like the trip meter's \"123.4\" beside \"048213\". An O or an I/l touching a digit is a 0 or a 1. Leading\n    * zeros are the dial's padding and go.\n    *\n    * @param text what the recognizer read, lines joined by {@code '\\n'}\n    * @return the number, or null when there is no digit\n    */\n   public static String odometer(String text)\n   {\n      String best = \"\";\n      for (String line : text.split(\"\\n\"))\n      {\n         StringBuilder run = new StringBuilder();\n         int since = 0; // digits since the run began or since its last thousands gap\n         for (int i = 0; i <= line.length(); i++)\n         {\n            char c = i < line.length() ? line.charAt(i) : '\\n';\n            boolean nextDigit = i + 1 < line.length() && digit(line.charAt(i + 1)) >= 0;\n            int d = digit(c);\n            if (d >= 0 && (Character.isDigit(c) || run.length() > 0 || nextDigit))\n            {\n               run.append((char) ('0' + d));\n               since++;\n            }\n            else if ((c == ' ' || c == '.' || c == ',') && since >= 1 && since <= 3 && group(line, i + 1) == 3)\n               since = 0;\n            else\n            {\n               if (run.length() > best.length())\n                  best = run.toString();\n               run.setLength(0);\n               since = 0;\n            }\n         }\n      }\n      if (best.isEmpty())\n         return null;\n      int z = 0;\n      while (z < best.length() - 1 && best.charAt(z) == '0')\n         z++;\n      return best.substring(z);\n   }\n\n   /** How many digits (or look-alikes) start at {@code from}. */\n   private static int group(String line, int from)\n   {\n      int i = from;\n      while (i < line.length() && digit(line.charAt(i)) >= 0)\n         i++;\n      return i - from;\n   }\n\n   /** A digit, or a letter recognizers mistake for one; -1 for anything else. */\n   private static int digit(char c)\n   {\n      if (c >= '0' && c <= '9')\n         return c - '0';\n      if (c == 'O' || c == 'o')\n         return 0;\n      if (c == 'I' || c == 'l')\n         return 1;\n      return -1;\n   }\n\n   private void setState(State s, String button)\n   {\n      state = s;\n      go.setText(button);\n      go.setEnabled(s != State.READING);\n      go.setValidLayout(false);\n      Screen.repaint();\n   }\n\n   private void show(String s)\n   {\n      value.setText(s);\n      value.invalidateMeasure(); // the pinned band must grow with it (see Recipe.pin)\n      Screen.repaint();\n   }\n\n   private void say(String s)\n   {\n      // To the log too: a person reading this over ssh, or a log after the fact, sees the verdict.\n      Debug.log(\"[odometer] \" + s);\n      status.setText(s);\n      status.invalidateMeasure(); // the pinned band must grow with it (see Recipe.pin)\n      Screen.repaint();\n   }\n\n   @Override\n   public void onRemove()\n   {\n      if (state == State.LIVE)\n         stop();\n      state = State.OFF;\n      super.onRemove();\n   }\n\n   /** The strip: the band of the latest frame, and a guide over it. */\n   private final class Viewfinder extends Component\n   {\n      /** The latest frame; written on the camera's thread, read on the paint thread. */\n      volatile Image frame;\n\n      void onFrame(Image f)\n      {\n         // The pixels changed under the same Image: drop the texture so the next paint uploads them.\n         f.freeTexture();\n         boolean first = frame == null;\n         frame = f;\n         if (first)\n            Main.runOnPaintThread(() -> say(\"Fit the digits inside the strip, then tap Read.\"));\n         Screen.repaint();\n      }\n\n      @Override\n      public void onPaint(Paint paint) throws Exception\n      {\n         super.onPaint(paint);\n         Image f = frame;\n         int w = getWidth(), h = getHeight();\n         if (f == null || w <= 0 || h <= 0)\n            return;\n         int[] b = band(f.getWidth(), f.getHeight(), w, h);\n         paint.image(f, 0, 0, w, h, b[0], b[1], b[2], b[3]);\n         // The guide: where the digits go. Inset, so it reads as \"inside\" and not as a border.\n         int in = Styles.fontHeight / 3, t = Math.max(2, Styles.fontHeight / 10);\n         int c = state == State.DONE ? 0xFFFFFFFF : Tones.accent().color();\n         paint.line(in, in, w - in, in, c, t);\n         paint.line(in, h - in - t, w - in, h - in - t, c, t);\n         paint.line(in, in, in, h - in, c, t);\n         paint.line(w - in - t, in, w - in - t, h - in, c, t);\n      }\n   }\n}\n"
    }
  ],
  "lines": 402,
  "sharedFiles": [
    {
      "path": "dev/cf/recipe/Recipe.java",
      "name": "Recipe.java",
      "code": "package dev.cf.recipe;\n\nimport dev.cf.*;\nimport dev.crossfly.ui.*;\nimport dev.crossfly.ui.graphics.*;\nimport dev.crossfly.ui.layout.*;\nimport dev.crossfly.ui.skin.*;\nimport dev.crossfly.ui.style.*;\n\nimport static dev.crossfly.ui.Widgets.*;\n\n/**\n * Base of a <b>recipe</b>: one screen that shows one thing, and nothing else.\n *\n * <p>A recipe is not a sample. A sample shows a component with all its knobs; a recipe answers a\n * question somebody actually has — \"how do I put a filter sheet over a list?\", \"how do I make this\n * follow the theme?\" — in as few lines as it takes, so the answer can be read in one screen and\n * copied without editing.</p>\n *\n * <p>That is the whole design constraint: <b>the boilerplate lives here, the substance lives in the\n * subclass.</b> A recipe that spends twenty lines building a title bar teaches the title bar.</p>\n *\n * <p>Write one as:</p>\n *\n * <pre>\n * public class MyRecipe extends Recipe\n * {\n *    public String title() { return \"What this shows\"; }\n *    public String note()  { return \"The one sentence worth knowing.\"; }\n *\n *    public void build(Component body) throws Exception\n *    {\n *       body.add(LAY_AUTO, new Button(\"Tap\"));\n *    }\n * }\n * </pre>\n */\npublic abstract class Recipe extends SampleWindow implements Described\n{\n   @Override\n   public abstract String title();\n\n   /**\n    * The one thing a reader should take away — printed above the recipe, in caption tone.\n    *\n    * @return the note, or null for none\n    */\n   @Override\n   public String note()\n   {\n      return null;\n   }\n\n   /**\n    * Builds the recipe.\n    *\n    * @param body a vertical, scrollable container with sensible padding — add to it and stop\n    * @throws Exception when building fails\n    */\n   public abstract void build(Component body) throws Exception;\n\n   @Override\n   public void onAdd(boolean firstTime) throws Exception\n   {\n      addTopBar(title());\n\n      // The page, not the surface. addTopBar just set fullscreenColor to the surface,\n      // and on a white page a white card has no edges — see Tones.page(). Re-derived on restyle so\n      // a dark-mode flip does not leave the light page behind.\n      fullscreenColor = Tones.page();\n      onRestyle(() -> fullscreenColor = Tones.page());\n\n      contents.setLayout(new VerticalLayout(Position.START, Position.START));\n      contents.getStyle()\n         .ingap(Gap.ofFont(1f, 1f, 1f, 1f));\n\n      String n = note();\n      if (n != null)\n         contents.add(LAY_AUTO, outgap(nota(n), 0, 0, 0, 1f));\n\n      Component body = new Component();\n      body.setLayout(new VerticalLayout(Position.START, Position.START));\n      body.setScrollable(ScrollDirection.VERTICAL);\n      body.testTag(\"recipe.body\");\n      contents.add(LAY_FILL, body);\n      build(body);\n      if (pinned != null)\n      {\n         // Measured again once its labels have sizes: the band's AUTO height is taken in the first\n         // layout, before them, and on the iPhone it showed only its first line - the odometer's\n         // \"No text recognizer on this device.\" was set and never seen (17/10/2026).\n         final Component band = pinned;\n         onUI(band::invalidateMeasure);\n      }\n      if (pinned != null)\n         body.add(LAY_AUTO, new Component()\n         {\n            @Override\n            public int getAutoHeight()\n            {\n               // Room for the last row to scroll clear of the floating band.\n               return pinned.getAutoHeight();\n            }\n         });\n   }\n\n   /**\n    * The note, as a tinted band rather than three lines of grey.\n    *\n    * <p>It is the one sentence the reader is meant to leave with, and it used to be the least\n    * legible thing on the screen: caption-coloured, caption-sized, indistinguishable from the\n    * captions further down that are only labels. A tone and a glyph cost nothing here — nobody has\n    * to read the band to know what it is.</p>\n    *\n    * @param n the note text\n    * @return the band\n    * @throws Exception when building fails\n    */\n   /**\n    * Pins {@code c} to the bottom of the screen, in a band that FLOATS over the scrolling body: it\n    * stays on screen however far the body is scrolled, and the body scrolls underneath it. That is the\n    * place a recipe says what just happened. An answer at the top of the body went out of view as soon\n    * as the person scrolled down to the button that produced it (14/10/2026).\n    * <p>\n    * The band is 80% OPAQUE (the surface at alpha 0xCC), as the user asked; it floats rather than\n    * sitting below the body because a transparency with nothing behind it cannot be seen -- the first\n    * version had the band under the body, and the person saw a plain bar. The body gets a spacer as\n    * tall as the band at its end, so its last row can still scroll clear of it.\n    *\n    * @param c the component to pin, usually the answer label\n    * @return {@code c}, to chain\n    * @throws Exception if the layout refuses\n    */\n   protected <T extends Component> T pin(T c) throws Exception\n   {\n      Component band = new Component();\n      band.setLayout(new VerticalLayout(Position.START, Position.START));\n      band.style(Skin.get()\n         .card()\n         .backColor(Styles.backColor.withAlpha(0xCC))\n         .borderColor(Styles.borderColor.withAlpha(0x33))\n         .borderRadius(0)\n         .shadow(0, 0, 0, 0)\n         .ingap(Gap.ofFont(1f, 1f, 1f, 1.2f))\n         .outgap(Gap.ofFont(0)));\n      band.add(LAY_AUTO, c);\n      // Floated: positioned at the bottom, full width, over whatever the body has there.\n      band.setRelativeBounds(Relative.START, Relative.END, Relative.FULL, Relative.AUTO);\n      contents.add(LAY_AUTO, band);\n      pinned = band;\n      return c;\n   }\n\n   /** The floating band {@link #pin} made, or null; the body ends in a spacer as tall as it. */\n   private Component pinned;\n\n   private Component nota(String n) throws Exception\n   {\n      Component band = new Component();\n      band.setLayout(new GridLayout(Position.START, Position.START));\n      band.style(Skin.get()\n         .card()\n         .backColor(Tones.accent().withAlpha(0x16))\n         .borderColor(Tones.accent().withAlpha(0x4A))\n         .shadow(0, 0, 0, 0)\n         .outgap(Gap.ofFont(0)));\n      // 0xE88E is the outlined \"info\". The glyph column hugs; the text takes the rest and wraps.\n      // The weights are GridLayout's own — 0f hugs, 1f takes what is left. LAY_FILL belongs to the\n      // vertical and horizontal layouts and means nothing here: it collapsed the text to nothing.\n      band.add(LAY_AUTO, 1f, matglyph(0xE88E, Tones.accent(), 1.05f),\n         outgap(label(n, 1f, false, secondary(), Position.START), .5f, 0, 0, 0));\n      return band;\n   }\n}\n"
    },
    {
      "path": "dev/cf/SampleWindow.java",
      "name": "SampleWindow.java",
      "code": "package dev.cf;\n\nimport dev.crossfly.ui.*;\nimport dev.crossfly.ui.graphics.*;\nimport dev.crossfly.ui.layout.*;\nimport dev.crossfly.ui.style.*;\n\nimport static dev.cf.sample.CrossFlySamplesColors.*;\n\n/**\n * Base class for all CrossFly sample windows.\n * <p>\n * Provides common infrastructure:\n * <ul>\n *   <li>Shared color palette and styles from {@link dev.cf.sample.CrossFlySamplesColors}</li>\n *   <li>{@link #addTopBar(String, Button...)} — standard deep-indigo top bar with back button</li>\n *   <li>{@link #barButton(String, Runnable)} — helper to create icon buttons for the bar</li>\n * </ul>\n *\n * <h3>Usage</h3>\n * <pre>\n * public class MySample extends SampleWindow {\n *     public void onAdd(boolean firstTime) throws Exception {\n *         if (!firstTime) return;\n *         initWindow();\n *         addTopBar(\"My Sample\");\n *         // ... add content\n *     }\n * }\n * </pre>\n */\npublic abstract class SampleWindow extends Window\n{\n   /** The main content area of the window, added by {@link #addTopBar(String, Button...)}, for subclasses to add their content into. */\n   protected Component contents;\n\n   // ─── Top bar ─────────────────────────────────────────────────────────────\n\n   /**\n    * Builds and adds a standard top bar with a back (←) button on the left,\n    * a centered title, and any optional extra buttons on the right.\n    *\n    * @param title        the text shown in the bar\n    * @param rightButtons zero or more icon buttons placed aligned to the right\n    * @throws Exception if component construction fails\n    */\n   protected void addTopBar(String title, Button... rightButtons) throws Exception\n   {\n      fullscreenColor = Styles.backColor.color();\n      setLayout(new VerticalLayout());\n      add(0f, buildTopBar(title, rightButtons));\n      add(LAY_FILL, contents = new Component());\n      contents.getStyle()\n         .ingap(Gap.ofFont(0, .5f, 0, .5f));\n   }\n\n   /**\n    * Same as {@link #addTopBar(String, Button...)}, but returns the bar component\n    * instead of adding it, for callers that need to position it manually.\n    *\n    * @param title        the bar title\n    * @param rightButtons optional right-side icon buttons\n    * @return the assembled top-bar {@link Component}\n    * @throws Exception if component construction fails\n    */\n   protected Component buildTopBar(String title, Button... rightButtons) throws Exception\n   {\n      Button btBack = barButton(\"\\ue5c4\", this::hide);   // arrow_back\n      btBack.testTag(\"sample.back\");\n      // Padded on every side, like the bar's right buttons: with none above and below, the target\n      // was the arrow itself, too small to hit on a small phone (iPhone 5s, 17/10/2026).\n      btBack.getStyle()\n         .ingap(Gap.ofFont(.75f))\n         .outgap(Gap.ofFont(0, 0, .25f, 0));\n\n      Component bar = new Component().style(barStyle);\n      bar.setLayout(new GridLayout(Position.START, Position.CENTER));\n\n      int n = 2 + rightButtons.length;\n      Object[] params = new Object[n * 2];\n      params[0] = 0f;\n      params[1] = 1f;\n      for (int i = 0; i < rightButtons.length; i++)\n         params[2 + i] = 0f;\n      params[n] = btBack;\n      params[n + 1] = new Label(title).style(barTitleStyle);\n      for (int i = 0; i < rightButtons.length; i++)\n      {\n         params[n + 2 + i] = rightButtons[i];\n         rightButtons[i].getStyle()\n            .ingap(Gap.ofFont(.75f));\n      }\n\n      bar.add(params);\n      return bar;\n   }\n\n   /**\n    * Creates a standard bar icon button (transparent, white icon, pressed feedback).\n    *\n    * @param materialIcon the Material Icon unicode codepoint string\n    * @param action       the action to run when the button is tapped\n    * @return the configured {@link Button}\n    */\n   protected Button barButton(String materialIcon, Runnable action)\n   {\n      return new Button(new MaterialIcon(materialIcon)).style(barButtonNormal)\n         .pressedStyle(barButtonPressed)\n         .onAction(e -> action.run());\n   }\n}\n"
    }
  ],
  "page": "https://crossfly.dev/examples/odometer",
  "run": "https://crossfly.dev/playground/index.html?ex=odometer",
  "runLocally": "gradlew lwjgl3:run --args=\"classname dev.cf.recipe.R34Odometer\"",
  "license": "See crossfly.dev for licensing. The code is published to be copied into your own project."
}