# Capturing a signature

> A recipe — one screen answering one question.

White pad, not the theme surface: the image is captured to be printed. Under dark mode a themed pad captures white ink on navy, which is unusable downstream.

- 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.R19Signature`
- Topics: input, graphics
- Run it locally: `gradlew lwjgl3:run --args="classname dev.cf.recipe.R19Signature"`
- Run it in a browser: https://crossfly.dev/playground/index.html?ex=signature
- Page: https://crossfly.dev/examples/signature

## What this shows

Capturing a signature, and the three details that separate one that works in the field.

None of them is about drawing — `DrawingBoard` handles that. They are about what happens
around it.

## Framework types it uses

`Button`, `Component`, `ComponentStyle`, `DrawingBoard`, `Gap`, `GridLayout`, `Hint`, `MaterialIcon`, `Relative`, `Screen`, `Styles`, `VerticalLayout`, `Widgets`

## Source

### dev/cf/recipe/R19Signature.java

```java
package dev.cf.recipe;

import dev.cf.Tones;

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

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

/**
 * Capturing a signature, and the three details that separate one that works in the field.
 *
 * <p>None of them is about drawing — {@link DrawingBoard} handles that. They are about what happens
 * around it.</p>
 */
public class R19Signature extends Recipe
{
   private DrawingBoard board;
   private Button confirm;
   private boolean drawn;

   public String title()
   {
      return "Capturing a signature";
   }

   public String note()
   {
      return "White pad, not the theme surface: the image is captured to be printed. Under dark"
         + " mode a themed pad captures white ink on navy, which is unusable downstream.";
   }

   public void build(Component body) throws Exception
   {
      Component pad = new Component();
      pad.setLayout(new VerticalLayout(Position.START, Position.START));
      // 1 · The pad has a colour of its own. This is the ONE place ignoring the theme is right,
      // because the output is not a screen — it is an attachment on a document.
      pad.getStyle()
         .backColor(0xFFFFFFFF)
         .borderColor(Styles.borderColor)
         .borderThick(Styles.borderThick)
         .borderRadius(Styles.borderRadius)
         .autoHeight(Relative.ofFont(10f));
      pad.testTag("sign.pad");

      // 2 · The DrawingBoard reports strokes by letting you override its touch handlers. There is no
      // listener to look for, and adding one to the framework for this would be the wrong fix.
      board = new DrawingBoard()
      {
         @Override
         public void onTouchUp(float x, float y, int button) throws Exception
         {
            super.onTouchUp(x, y, button);
            setDrawn(true);
         }
      };
      board.setPenColor(0xFF111111);
      pad.add(LAY_FILL, board);
      body.add(LAY_AUTO, outgap(pad, 0, 0, 0, .8f));

      // Ink and pen, as in the DrawingBoard sample: a few ink colours and the thin/thick toggle.
      // A signature was black and hairline only (asked for on the iPhone, 17/10/2026).
      body.add(LAY_AUTO, outgap(inkPalette(), 0, 0, 0, .8f));

      Component row = new Component();
      row.setLayout(new GridLayout(Position.START, Position.CENTER));

      Button clear = new Button("Clear");
      clear.testTag("sign.clear");
      // Clear asks nothing: it is instantly and obviously reversible — you sign again.
      clear.onAction(c ->
      {
         board.clear();
         setDrawn(false);
         Screen.repaint();
      });

      // 3 · Confirm is dead until something is drawn. An empty signature is the easiest thing to
      // capture by accident and is indistinguishable from a real one once it is stored.
      confirm = Tones.action("Confirm");
      confirm.testTag("sign.confirm");
      confirm.setEnabled(false);
      pintarConfirm();
      confirm.onAction(c -> Hint.show("Signature captured", 2000));

      row.add(.5f, .5f, clear, confirm);
      body.add(LAY_AUTO, row);
   }

   /** Ink colours a signature is made with: black, navy, blue. */
   private static final int[] INKS = {0xFF111111, 0xFF1A237E, 0xFF1E88E5};

   /**
    * The ink row: one dot per ink and the pen-thickness toggle - the DrawingBoard sample's palette.
    *
    * @return the row
    * @throws Exception when building fails
    */
   private Component inkPalette() throws Exception
   {
      Component row = new Component();
      row.setLayout(new GridLayout(Position.START, Position.CENTER));
      Object[] params = new Object[(INKS.length + 1) * 2];
      final Button[] dots = new Button[INKS.length];
      for (int i = 0; i < INKS.length; i++)
      {
         final int ink = INKS[i], index = i;
         ComponentStyle dot = Styles.button()
            .copy()
            .backColor(0)
            .borderColor(0)
            .iconColor(ink)
            .ingap(Gap.ofFont(.4f, .3f, .4f, .3f));
         Button b = new Button(inkIcon(i == 0)).style(dot);
         b.testTag("sign.ink." + i);
         // The chosen ink carries a check: a pen colour you cannot see until you draw is a guess
         // (found by Test73, 19/10/2026 - tapping a dot changed nothing on the screen).
         b.onAction(e ->
         {
            board.setPenColor(ink);
            for (int k = 0; k < dots.length; k++)
            {
               dots[k].icon(inkIcon(k == index), dots[k].textPos);
               dots[k].setValidLayout(false);
            }
            Screen.repaint();
         });
         dots[i] = b;
         params[i] = 0f;
         params[INKS.length + 1 + i] = b;
      }
      ComponentStyle pen = Styles.button()
         .copy()
         .backColor(0)
         .borderColor(0)
         .iconColor(Styles.textColor)
         .ingap(Gap.ofFont(.4f, .3f, .4f, .3f));
      // brush, with the pen it gives: like the ink, the thickness in force is on the screen.
      Button thick = new Button("Thin", new MaterialIcon("\ue3ae", 1.4f), TextPos.TEXT_AT_RIGHT).style(pen);
      thick.testTag("sign.thick");
      thick.onAction(e ->
      {
         board.thick = !board.thick;
         thick.setText(board.thick ? "Thick" : "Thin");
      });
      params[INKS.length] = 0f;
      params[INKS.length * 2 + 1] = thick;
      row.add(params);
      return row;
   }

   /** A filled dot, and the same dot with a check for the ink in force. */
   private static MaterialIcon inkIcon(boolean chosen)
   {
      return new MaterialIcon(chosen ? "" : "", 1.5f);
   }

   private void setDrawn(boolean b)
   {
      if (drawn != b)
      {
         drawn = b;
         confirm.setEnabled(b);
         pintarConfirm();
      }
   }

   /**
    * Fills Confirm only while it can be pressed.
    *
    * <p>{@code setEnabled(false)} dims the label and stops the touch, and on the skin's outlined
    * button that is the whole story. A FILLED button is different: the fill keeps shouting "press
    * me" at a control that will not answer, which is worse than no styling at all. So the fill is
    * part of the state, not part of the button.</p>
    */
   private void pintarConfirm()
   {
      confirm.getStyle()
         .backColor(drawn ? Tones.accent() : Tones.accent().withAlpha(0x22))
         .textColor(drawn ? Styles.colorScheme().onPrimary : Tones.accent().withAlpha(0x88));
      confirm.setValidLayout(false);
   }
}
```

## 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());
   }
}
```
