# Reading an odometer (OCR)

> A recipe — one screen answering one question.

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.

- Framework: CrossFly (Java; one source for Android, iOS, desktop (Windows, macOS and Linux) and the web — Chrome, Firefox, Safari and other browsers)
- Entry point: `dev.cf.recipe.R34Odometer`
- Topics: device
- Run it locally: `gradlew lwjgl3:run --args="classname dev.cf.recipe.R34Odometer"`
- Run it in a browser: https://crossfly.dev/playground/index.html?ex=odometer
- Page: https://crossfly.dev/examples/odometer

## What this shows

Reading an odometer with the camera: a live strip, one tap, a number.

**The viewfinder is a strip, not a photo.** It is about as tall as the row of digits on a
dashboard, and that is the whole trick: the person lines the odometer up inside it, and the
recognizer is handed **exactly that band** of the frame &mdash; not the speedometer, the
trip meter, the clock or the outside temperature around it, every one of which is a number too.
`band` is the one rectangle both sides use: the strip paints it, the read crops it, so what
is read is what was seen.

The camera here is `videoPreview`, not a picker. A picker is a native
window over the whole screen; a preview is frames, and frames are an `Image` like any other,
painted by the strip's own `onPaint` &mdash; so it scrolls, clips and sits in a page. It is
started by a tap and stopped when the screen goes away: a camera that turns on because a screen
opened is a light coming on that nobody asked for.

`TextRecognition` returns lines of text; the number is this recipe's business. Dials
print leading zeros, recognizers split a long run with a space and read a 0 as an O, and
`odometer` undoes exactly those three, then keeps the longest run of digits.

## Framework types it uses

`Button`, `Capabilities`, `Capability`, `Component`, `Debug`, `Device`, `Image`, `Label`, `Main`, `Paint`, `PlatformUtils`, `Relative`, `Screen`, `Styles`, `TextRecognition`, `VerticalLayout`, `Widgets`

## Source

### dev/cf/recipe/R34Odometer.java

```java
package dev.cf.recipe;

import java.io.ByteArrayOutputStream;

import dev.cf.Tones;

import dev.crossfly.Capability;
import dev.crossfly.Main;
import dev.crossfly.PlatformUtils;
import dev.crossfly.capability.Capabilities;
import dev.crossfly.capability.TextRecognition;
import dev.crossfly.device.Device;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.image.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import dev.crossfly.util.Debug;

import static dev.crossfly.ui.Widgets.*;

/**
 * Reading an odometer with the camera: a live strip, one tap, a number.
 *
 * <p><b>The viewfinder is a strip, not a photo.</b> It is about as tall as the row of digits on a
 * dashboard, and that is the whole trick: the person lines the odometer up inside it, and the
 * recognizer is handed <b>exactly that band</b> of the frame &mdash; not the speedometer, the
 * trip meter, the clock or the outside temperature around it, every one of which is a number too.
 * {@link #band} is the one rectangle both sides use: the strip paints it, the read crops it, so what
 * is read is what was seen.</p>
 *
 * <p>The camera here is {@link PlatformUtils#videoPreview}, not a picker. A picker is a native
 * window over the whole screen; a preview is frames, and frames are an {@link Image} like any other,
 * painted by the strip's own {@code onPaint} &mdash; so it scrolls, clips and sits in a page. It is
 * started by a tap and stopped when the screen goes away: a camera that turns on because a screen
 * opened is a light coming on that nobody asked for.</p>
 *
 * <p>{@link TextRecognition} returns lines of text; the number is this recipe's business. Dials
 * print leading zeros, recognizers split a long run with a space and read a 0 as an O, and
 * {@link #odometer} undoes exactly those three, then keeps the longest run of digits.</p>
 */
public class R34Odometer extends Recipe
{
   /** The strip's height, in font heights: one row of dashboard digits and a little margin. */
   private static final float STRIP = 3.5f;

   /** Below this height the band is enlarged before it is read: recognizers want tall glyphs. */
   private static final int MIN_OCR_HEIGHT = 160;

   private Viewfinder strip;
   private Button go;
   private Label value, status;

   /** Off, live, reading, or showing what was read over the frozen frame. */
   private enum State { OFF, LIVE, READING, DONE }

   private State state = State.OFF;

   public String title()
   {
      return "Reading an odometer (OCR)";
   }

   public String note()
   {
      return "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.";
   }

   public void build(Component body) throws Exception
   {
      strip = new Viewfinder();
      strip.getStyle()
         .backColor(0xFF000000)
         .borderRadius(Styles.borderRadius)
         .autoHeight(Relative.ofFont(STRIP));
      strip.testTag("odometer.strip");
      body.add(LAY_AUTO, outgap(strip, 0, 0, 0, .8f));

      go = Tones.action("Open camera");
      go.testTag("odometer.go");
      go.onAction(e -> tap());
      body.add(LAY_AUTO, outgap(go, 0, 0, 0, 1.2f));

      // The reading is pinned below the scrolling body: with the strip and the button above it, it
      // sat under the fold and the person had to scroll to see what was read (14/10/2026).
      Component result = new Component();
      result.setLayout(new VerticalLayout(Position.START, Position.START));
      result.add(LAY_AUTO, label("ODOMETER", 1f, true, Styles.captionColor, Position.START));
      value = label("—", 3f, true, Styles.textColor, Position.START);
      value.testTag("odometer.value");
      result.add(LAY_AUTO, value);

      status = label("", 1f, false, Styles.captionColor, Position.START);
      status.testTag("odometer.status");
      result.add(LAY_AUTO, outgap(status, 0, .5f, 0, 0));
      pin(result);

      // Ask both before offering either. The camera alone is a viewfinder that reads nothing; the
      // recognizer alone has nothing to read.
      boolean cam = PlatformUtils.isSupported(Capability.VIDEO_PREVIEW);
      boolean ocr = PlatformUtils.isSupported(Capability.TEXT_RECOGNITION);
      if (!cam || !ocr)
      {
         go.setEnabled(false);
         // On an iPhone the recognizer is Apple's Vision, from iOS 13: say so, or "this device" reads
         // like a broken one (an iPhone 5s on iOS 12, 17/10/2026).
         say(!cam ? "No camera preview on this device."
            : "No text recognizer on this device." + (Device.platform != null && Device.platform.isIOS()
               ? " On iOS it needs iOS 13 or later." : ""));
      }
      else
         say("Point the camera at the odometer and fit the digits inside the strip.");
   }

   /** The one button walks the states: open the camera, read, and read again. */
   private void tap()
   {
      if (state == State.LIVE)
         read();
      else if (state != State.READING)
         start();
   }

   private void start()
   {
      strip.frame = null;
      try
      {
         // Ask for more pixels than the strip shows: the band is a slice of the frame, and the
         // recognizer reads that slice, not the screen.
         if (!PlatformUtils.instance.videoPreview(1280, 720, strip::onFrame))
         {
            say("The camera did not open.");
            return;
         }
      }
      catch (Exception e)
      {
         Debug.handle(e);
         say("The camera did not open: " + e.getMessage());
         return;
      }
      setState(State.LIVE, "Read");
      say("Fit the digits inside the strip, then tap Read.");
   }

   private void stop()
   {
      try
      {
         PlatformUtils.instance.videoPreview(0, 0, null);
      }
      catch (Exception e)
      {
         Debug.handle(e);
      }
   }

   private void read()
   {
      Image frame = strip.frame;
      if (frame == null)
      {
         say("No picture from the camera yet.");
         return;
      }
      // Stop first: the frame stays on the strip, frozen, and no camera thread writes into it
      // while it is being cut.
      stop();
      byte[] png;
      try
      {
         png = cut(frame, strip.getWidth(), strip.getHeight());
      }
      catch (Exception e)
      {
         Debug.handle(e);
         say("Could not cut the picture: " + e.getMessage());
         setState(State.DONE, "Try again");
         return;
      }
      setState(State.READING, "Reading…");
      say("Reading…");
      // -Dcrossfly.ocr.debug=1 keeps the exact PNG the recognizer gets: when nothing is read, the
      // picture is what tells a bad cut or a blurred frame from a recognizer that cannot read.
      if ("1".equals(System.getProperty("crossfly.ocr.debug")))
         try (java.io.FileOutputStream o = new java.io.FileOutputStream("ocr-" + System.currentTimeMillis() + ".png"))
         {
            o.write(png);
         }
         catch (Exception e)
         {
            Debug.log("[odometer] debug dump failed: " + e);
         }
      Capabilities.textRecognition().recognize(png, (result, error) ->
      {
         // Arrives on the paint thread, so the labels can be touched directly.
         setState(State.DONE, "Read again");
         if (error != null)
         {
            show("—");
            say("The recognizer could not read the picture: " + error);
            return;
         }
         String n = odometer(result.text);
         show(n == null ? "—" : n);
         say(n == null
            ? (result.text.isEmpty() ? "No text in the strip." : "No number in: " + result.text.replace('\n', ' '))
            : "Read: " + result.text.replace('\n', ' '));
      });
   }

   /**
    * The rectangle of a frame that a viewfinder of the given size shows, centred, cut to fill
    * ("cover"): the strip paints this and the read crops this, so the two cannot disagree.
    *
    * @param imgW  the frame's width
    * @param imgH  the frame's height
    * @param viewW the viewfinder's width
    * @param viewH the viewfinder's height
    * @return {x, y, w, h} in the frame's pixels
    */
   public static int[] band(int imgW, int imgH, int viewW, int viewH)
   {
      float scale = Math.max(viewW / (float) imgW, viewH / (float) imgH);
      int w = Math.min(imgW, Math.max(1, Math.round(viewW / scale)));
      int h = Math.min(imgH, Math.max(1, Math.round(viewH / scale)));
      return new int[]{(imgW - w) / 2, (imgH - h) / 2, w, h};
   }

   /**
    * Cuts the band a viewfinder shows out of a frame and encodes it for the recognizer, enlarged
    * when it is short &mdash; a 60-pixel strip of digits is legible to a person and too small for
    * most recognizers.
    *
    * @param frame the camera frame
    * @param viewW the viewfinder's width
    * @param viewH the viewfinder's height
    * @return a PNG of the band
    * @throws Exception when the image cannot be cut or encoded
    */
   public static byte[] cut(Image frame, int viewW, int viewH) throws Exception
   {
      int[] b = band(frame.getWidth(), frame.getHeight(), viewW, viewH);
      Image area = frame.getArea(b[0], b[1], b[2], b[3]);
      Image big = b[3] < MIN_OCR_HEIGHT ? area.resizeHeight(MIN_OCR_HEIGHT) : area;
      try
      {
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         big.toPng(out);
         return out.toByteArray();
      }
      finally
      {
         if (big != area)
            big.dispose();
         area.dispose();
      }
   }

   /**
    * The odometer's number in what the recognizer read: the longest run of digits.
    * <p>
    * A space, a dot or a comma with one to three digits before it and exactly three after is a
    * thousands gap ("48 213", "48.213"), not the end of the number; anything else is a second
    * 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
    * zeros are the dial's padding and go.
    *
    * @param text what the recognizer read, lines joined by {@code '\n'}
    * @return the number, or null when there is no digit
    */
   public static String odometer(String text)
   {
      String best = "";
      for (String line : text.split("\n"))
      {
         StringBuilder run = new StringBuilder();
         int since = 0; // digits since the run began or since its last thousands gap
         for (int i = 0; i <= line.length(); i++)
         {
            char c = i < line.length() ? line.charAt(i) : '\n';
            boolean nextDigit = i + 1 < line.length() && digit(line.charAt(i + 1)) >= 0;
            int d = digit(c);
            if (d >= 0 && (Character.isDigit(c) || run.length() > 0 || nextDigit))
            {
               run.append((char) ('0' + d));
               since++;
            }
            else if ((c == ' ' || c == '.' || c == ',') && since >= 1 && since <= 3 && group(line, i + 1) == 3)
               since = 0;
            else
            {
               if (run.length() > best.length())
                  best = run.toString();
               run.setLength(0);
               since = 0;
            }
         }
      }
      if (best.isEmpty())
         return null;
      int z = 0;
      while (z < best.length() - 1 && best.charAt(z) == '0')
         z++;
      return best.substring(z);
   }

   /** How many digits (or look-alikes) start at {@code from}. */
   private static int group(String line, int from)
   {
      int i = from;
      while (i < line.length() && digit(line.charAt(i)) >= 0)
         i++;
      return i - from;
   }

   /** A digit, or a letter recognizers mistake for one; -1 for anything else. */
   private static int digit(char c)
   {
      if (c >= '0' && c <= '9')
         return c - '0';
      if (c == 'O' || c == 'o')
         return 0;
      if (c == 'I' || c == 'l')
         return 1;
      return -1;
   }

   private void setState(State s, String button)
   {
      state = s;
      go.setText(button);
      go.setEnabled(s != State.READING);
      go.setValidLayout(false);
      Screen.repaint();
   }

   private void show(String s)
   {
      value.setText(s);
      value.invalidateMeasure(); // the pinned band must grow with it (see Recipe.pin)
      Screen.repaint();
   }

   private void say(String s)
   {
      // To the log too: a person reading this over ssh, or a log after the fact, sees the verdict.
      Debug.log("[odometer] " + s);
      status.setText(s);
      status.invalidateMeasure(); // the pinned band must grow with it (see Recipe.pin)
      Screen.repaint();
   }

   @Override
   public void onRemove()
   {
      if (state == State.LIVE)
         stop();
      state = State.OFF;
      super.onRemove();
   }

   /** The strip: the band of the latest frame, and a guide over it. */
   private final class Viewfinder extends Component
   {
      /** The latest frame; written on the camera's thread, read on the paint thread. */
      volatile Image frame;

      void onFrame(Image f)
      {
         // The pixels changed under the same Image: drop the texture so the next paint uploads them.
         f.freeTexture();
         boolean first = frame == null;
         frame = f;
         if (first)
            Main.runOnPaintThread(() -> say("Fit the digits inside the strip, then tap Read."));
         Screen.repaint();
      }

      @Override
      public void onPaint(Paint paint) throws Exception
      {
         super.onPaint(paint);
         Image f = frame;
         int w = getWidth(), h = getHeight();
         if (f == null || w <= 0 || h <= 0)
            return;
         int[] b = band(f.getWidth(), f.getHeight(), w, h);
         paint.image(f, 0, 0, w, h, b[0], b[1], b[2], b[3]);
         // The guide: where the digits go. Inset, so it reads as "inside" and not as a border.
         int in = Styles.fontHeight / 3, t = Math.max(2, Styles.fontHeight / 10);
         int c = state == State.DONE ? 0xFFFFFFFF : Tones.accent().color();
         paint.line(in, in, w - in, in, c, t);
         paint.line(in, h - in - t, w - in, h - in - t, c, t);
         paint.line(in, in, in, h - in, c, t);
         paint.line(w - in - t, in, w - in - t, h - in, c, t);
      }
   }
}
```

## Shared scaffolding

Not part of this recipe, but read by it: the base class every recipe inherits its top bar and scrolling body from.

### dev/cf/recipe/Recipe.java

```java
package dev.cf.recipe;

import dev.cf.*;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.skin.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/**
 * Base of a <b>recipe</b>: one screen that shows one thing, and nothing else.
 *
 * <p>A recipe is not a sample. A sample shows a component with all its knobs; a recipe answers a
 * question somebody actually has — "how do I put a filter sheet over a list?", "how do I make this
 * follow the theme?" — in as few lines as it takes, so the answer can be read in one screen and
 * copied without editing.</p>
 *
 * <p>That is the whole design constraint: <b>the boilerplate lives here, the substance lives in the
 * subclass.</b> A recipe that spends twenty lines building a title bar teaches the title bar.</p>
 *
 * <p>Write one as:</p>
 *
 * <pre>
 * public class MyRecipe extends Recipe
 * {
 *    public String title() { return "What this shows"; }
 *    public String note()  { return "The one sentence worth knowing."; }
 *
 *    public void build(Component body) throws Exception
 *    {
 *       body.add(LAY_AUTO, new Button("Tap"));
 *    }
 * }
 * </pre>
 */
public abstract class Recipe extends SampleWindow implements Described
{
   @Override
   public abstract String title();

   /**
    * The one thing a reader should take away — printed above the recipe, in caption tone.
    *
    * @return the note, or null for none
    */
   @Override
   public String note()
   {
      return null;
   }

   /**
    * Builds the recipe.
    *
    * @param body a vertical, scrollable container with sensible padding — add to it and stop
    * @throws Exception when building fails
    */
   public abstract void build(Component body) throws Exception;

   @Override
   public void onAdd(boolean firstTime) throws Exception
   {
      addTopBar(title());

      // The page, not the surface. addTopBar just set fullscreenColor to the surface,
      // and on a white page a white card has no edges — see Tones.page(). Re-derived on restyle so
      // a dark-mode flip does not leave the light page behind.
      fullscreenColor = Tones.page();
      onRestyle(() -> fullscreenColor = Tones.page());

      contents.setLayout(new VerticalLayout(Position.START, Position.START));
      contents.getStyle()
         .ingap(Gap.ofFont(1f, 1f, 1f, 1f));

      String n = note();
      if (n != null)
         contents.add(LAY_AUTO, outgap(nota(n), 0, 0, 0, 1f));

      Component body = new Component();
      body.setLayout(new VerticalLayout(Position.START, Position.START));
      body.setScrollable(ScrollDirection.VERTICAL);
      body.testTag("recipe.body");
      contents.add(LAY_FILL, body);
      build(body);
      if (pinned != null)
      {
         // Measured again once its labels have sizes: the band's AUTO height is taken in the first
         // layout, before them, and on the iPhone it showed only its first line - the odometer's
         // "No text recognizer on this device." was set and never seen (17/10/2026).
         final Component band = pinned;
         onUI(band::invalidateMeasure);
      }
      if (pinned != null)
         body.add(LAY_AUTO, new Component()
         {
            @Override
            public int getAutoHeight()
            {
               // Room for the last row to scroll clear of the floating band.
               return pinned.getAutoHeight();
            }
         });
   }

   /**
    * The note, as a tinted band rather than three lines of grey.
    *
    * <p>It is the one sentence the reader is meant to leave with, and it used to be the least
    * legible thing on the screen: caption-coloured, caption-sized, indistinguishable from the
    * captions further down that are only labels. A tone and a glyph cost nothing here — nobody has
    * to read the band to know what it is.</p>
    *
    * @param n the note text
    * @return the band
    * @throws Exception when building fails
    */
   /**
    * Pins {@code c} to the bottom of the screen, in a band that FLOATS over the scrolling body: it
    * stays on screen however far the body is scrolled, and the body scrolls underneath it. That is the
    * place a recipe says what just happened. An answer at the top of the body went out of view as soon
    * as the person scrolled down to the button that produced it (14/10/2026).
    * <p>
    * The band is 80% OPAQUE (the surface at alpha 0xCC), as the user asked; it floats rather than
    * sitting below the body because a transparency with nothing behind it cannot be seen -- the first
    * version had the band under the body, and the person saw a plain bar. The body gets a spacer as
    * tall as the band at its end, so its last row can still scroll clear of it.
    *
    * @param c the component to pin, usually the answer label
    * @return {@code c}, to chain
    * @throws Exception if the layout refuses
    */
   protected <T extends Component> T pin(T c) throws Exception
   {
      Component band = new Component();
      band.setLayout(new VerticalLayout(Position.START, Position.START));
      band.style(Skin.get()
         .card()
         .backColor(Styles.backColor.withAlpha(0xCC))
         .borderColor(Styles.borderColor.withAlpha(0x33))
         .borderRadius(0)
         .shadow(0, 0, 0, 0)
         .ingap(Gap.ofFont(1f, 1f, 1f, 1.2f))
         .outgap(Gap.ofFont(0)));
      band.add(LAY_AUTO, c);
      // Floated: positioned at the bottom, full width, over whatever the body has there.
      band.setRelativeBounds(Relative.START, Relative.END, Relative.FULL, Relative.AUTO);
      contents.add(LAY_AUTO, band);
      pinned = band;
      return c;
   }

   /** The floating band {@link #pin} made, or null; the body ends in a spacer as tall as it. */
   private Component pinned;

   private Component nota(String n) throws Exception
   {
      Component band = new Component();
      band.setLayout(new GridLayout(Position.START, Position.START));
      band.style(Skin.get()
         .card()
         .backColor(Tones.accent().withAlpha(0x16))
         .borderColor(Tones.accent().withAlpha(0x4A))
         .shadow(0, 0, 0, 0)
         .outgap(Gap.ofFont(0)));
      // 0xE88E is the outlined "info". The glyph column hugs; the text takes the rest and wraps.
      // The weights are GridLayout's own — 0f hugs, 1f takes what is left. LAY_FILL belongs to the
      // vertical and horizontal layouts and means nothing here: it collapsed the text to nothing.
      band.add(LAY_AUTO, 1f, matglyph(0xE88E, Tones.accent(), 1.05f),
         outgap(label(n, 1f, false, secondary(), Position.START), .5f, 0, 0, 0));
      return band;
   }
}
```

### dev/cf/SampleWindow.java

```java
package dev.cf;

import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

import static dev.cf.sample.CrossFlySamplesColors.*;

/**
 * Base class for all CrossFly sample windows.
 * <p>
 * Provides common infrastructure:
 * <ul>
 *   <li>Shared color palette and styles from {@link dev.cf.sample.CrossFlySamplesColors}</li>
 *   <li>{@link #addTopBar(String, Button...)} — standard deep-indigo top bar with back button</li>
 *   <li>{@link #barButton(String, Runnable)} — helper to create icon buttons for the bar</li>
 * </ul>
 *
 * <h3>Usage</h3>
 * <pre>
 * public class MySample extends SampleWindow {
 *     public void onAdd(boolean firstTime) throws Exception {
 *         if (!firstTime) return;
 *         initWindow();
 *         addTopBar("My Sample");
 *         // ... add content
 *     }
 * }
 * </pre>
 */
public abstract class SampleWindow extends Window
{
   /** The main content area of the window, added by {@link #addTopBar(String, Button...)}, for subclasses to add their content into. */
   protected Component contents;

   // ─── Top bar ─────────────────────────────────────────────────────────────

   /**
    * Builds and adds a standard top bar with a back (←) button on the left,
    * a centered title, and any optional extra buttons on the right.
    *
    * @param title        the text shown in the bar
    * @param rightButtons zero or more icon buttons placed aligned to the right
    * @throws Exception if component construction fails
    */
   protected void addTopBar(String title, Button... rightButtons) throws Exception
   {
      fullscreenColor = Styles.backColor.color();
      setLayout(new VerticalLayout());
      add(0f, buildTopBar(title, rightButtons));
      add(LAY_FILL, contents = new Component());
      contents.getStyle()
         .ingap(Gap.ofFont(0, .5f, 0, .5f));
   }

   /**
    * Same as {@link #addTopBar(String, Button...)}, but returns the bar component
    * instead of adding it, for callers that need to position it manually.
    *
    * @param title        the bar title
    * @param rightButtons optional right-side icon buttons
    * @return the assembled top-bar {@link Component}
    * @throws Exception if component construction fails
    */
   protected Component buildTopBar(String title, Button... rightButtons) throws Exception
   {
      Button btBack = barButton("\ue5c4", this::hide);   // arrow_back
      btBack.testTag("sample.back");
      // Padded on every side, like the bar's right buttons: with none above and below, the target
      // was the arrow itself, too small to hit on a small phone (iPhone 5s, 17/10/2026).
      btBack.getStyle()
         .ingap(Gap.ofFont(.75f))
         .outgap(Gap.ofFont(0, 0, .25f, 0));

      Component bar = new Component().style(barStyle);
      bar.setLayout(new GridLayout(Position.START, Position.CENTER));

      int n = 2 + rightButtons.length;
      Object[] params = new Object[n * 2];
      params[0] = 0f;
      params[1] = 1f;
      for (int i = 0; i < rightButtons.length; i++)
         params[2 + i] = 0f;
      params[n] = btBack;
      params[n + 1] = new Label(title).style(barTitleStyle);
      for (int i = 0; i < rightButtons.length; i++)
      {
         params[n + 2 + i] = rightButtons[i];
         rightButtons[i].getStyle()
            .ingap(Gap.ofFont(.75f));
      }

      bar.add(params);
      return bar;
   }

   /**
    * Creates a standard bar icon button (transparent, white icon, pressed feedback).
    *
    * @param materialIcon the Material Icon unicode codepoint string
    * @param action       the action to run when the button is tapped
    * @return the configured {@link Button}
    */
   protected Button barButton(String materialIcon, Runnable action)
   {
      return new Button(new MaterialIcon(materialIcon)).style(barButtonNormal)
         .pressedStyle(barButtonPressed)
         .onAction(e -> action.run());
   }
}
```
