{
  "id": "device",
  "kind": "recipe",
  "tags": [
    "device"
  ],
  "title": "Camera, barcode and GPS",
  "note": "Ask whether the device HAS it before offering it. PlatformUtils.isSupported answers per capability, at runtime — never a check on which platform this is.",
  "className": "R21Device",
  "package": "dev.cf.recipe",
  "doc": "<p>Camera, barcode and location — the three that can say no.</p>\n\n<p>These differ from <code>R20Pickers</code> in one way that changes the whole screen: the hardware\nmay be missing, the permission may be refused, and the user may cancel. <b>Three outcomes, not\ntwo</b>, and the one that gets forgotten is the first.</p>\n\n<p>Forgetting it produces the worst version of this screen: a button that does nothing when\ntapped, on a desktop with no camera, with no explanation. A person holding the device has no way\nto tell that from a bug — so the availability check happens before the button exists, and an\nunavailable capture says why instead of pretending.</p>\n\n<p>The second half of the screen is the device APIs that are not pickers — vibration, speech\nboth ways, the battery, the network. They are here for the same reason: each is missing\nsomewhere, and on the web \"somewhere\" is decided by the browser the person opened rather than by\nthe build, so the same bundle shows a different set of enabled rows on a phone and on a laptop.\nOne row is the exception that proves the rule: <b>Lock to portrait</b> stays disabled on the web\nalthough <code>setScreenRotation</code> really does attempt the lock there, because the browser keeps\none only in fullscreen — a page state, not device hardware, and therefore not something\n<code>isSupported</code> could answer honestly at the moment an app asks.</p>\n\n<p>Two more things worth knowing before you embed one of these. A camera or map picker is a\n<b>native view layered on top of the GL canvas</b>: it does not scroll, does not clip to a\nparent, and does not take part in a transition — which is why such a picker turns transitions\noff, and why you open it rather than putting it in a page. And <code>BarcodePicker</code> is\nabstract: you subclass it to say what a scan means.</p>",
  "api": [
    {
      "name": "BarcodePicker",
      "doc": "/docs/ui/widgets/barcodepicker"
    },
    {
      "name": "Button",
      "doc": "/docs/ui/widgets/button"
    },
    {
      "name": "Capability",
      "doc": null
    },
    {
      "name": "Card",
      "doc": null
    },
    {
      "name": "Component",
      "doc": null
    },
    {
      "name": "Device",
      "doc": null
    },
    {
      "name": "Gap",
      "doc": null
    },
    {
      "name": "GridLayout",
      "doc": null
    },
    {
      "name": "Label",
      "doc": "/docs/ui/widgets/label"
    },
    {
      "name": "LocationPicker",
      "doc": "/docs/ui/widgets/locationpicker"
    },
    {
      "name": "MaterialIcon",
      "doc": null
    },
    {
      "name": "PalColor",
      "doc": null
    },
    {
      "name": "PhotoPicker",
      "doc": "/docs/ui/widgets/photopicker"
    },
    {
      "name": "PlatformUtils",
      "doc": null
    },
    {
      "name": "Screen",
      "doc": null
    },
    {
      "name": "Styles",
      "doc": null
    },
    {
      "name": "UIConstants",
      "doc": null
    },
    {
      "name": "VerticalLayout",
      "doc": null
    },
    {
      "name": "Widgets",
      "doc": null
    }
  ],
  "files": [
    {
      "path": "dev/cf/recipe/R21Device.java",
      "name": "R21Device.java",
      "code": "package dev.cf.recipe;\n\nimport dev.cf.Tones;\n\nimport dev.crossfly.Capability;\nimport dev.crossfly.PlatformUtils;\nimport dev.crossfly.device.Device;\nimport dev.crossfly.ui.*;\nimport dev.crossfly.ui.color.*;\nimport dev.crossfly.ui.graphics.*;\nimport dev.crossfly.ui.layout.*;\nimport dev.crossfly.ui.picker.*;\nimport dev.crossfly.ui.style.*;\n\nimport static dev.crossfly.ui.Widgets.*;\n\n/**\n * Camera, barcode and location — the three that can say no.\n *\n * <p>These differ from {@link R20Pickers} in one way that changes the whole screen: the hardware\n * may be missing, the permission may be refused, and the user may cancel. <b>Three outcomes, not\n * two</b>, and the one that gets forgotten is the first.</p>\n *\n * <p>Forgetting it produces the worst version of this screen: a button that does nothing when\n * tapped, on a desktop with no camera, with no explanation. A person holding the device has no way\n * to tell that from a bug — so the availability check happens before the button exists, and an\n * unavailable capture says why instead of pretending.</p>\n *\n * <p>The second half of the screen is the device APIs that are not pickers — vibration, speech\n * both ways, the battery, the network. They are here for the same reason: each is missing\n * somewhere, and on the web \"somewhere\" is decided by the browser the person opened rather than by\n * the build, so the same bundle shows a different set of enabled rows on a phone and on a laptop.\n * One row is the exception that proves the rule: <b>Lock to portrait</b> stays disabled on the web\n * although {@code setScreenRotation} really does attempt the lock there, because the browser keeps\n * one only in fullscreen — a page state, not device hardware, and therefore not something\n * {@code isSupported} could answer honestly at the moment an app asks.</p>\n *\n * <p>Two more things worth knowing before you embed one of these. A camera or map picker is a\n * <b>native view layered on top of the GL canvas</b>: it does not scroll, does not clip to a\n * parent, and does not take part in a transition — which is why such a picker turns transitions\n * off, and why you open it rather than putting it in a page. And {@link BarcodePicker} is\n * abstract: you subclass it to say what a scan means.</p>\n */\npublic class R21Device extends Recipe\n{\n   private Label answer;\n\n   public String title()\n   {\n      return \"Camera, barcode and GPS\";\n   }\n\n   public String note()\n   {\n      return \"Ask whether the device HAS it before offering it. PlatformUtils.isSupported answers\"\n         + \" per capability, at runtime — never a check on which platform this is.\";\n   }\n\n   public void build(Component body) throws Exception\n   {\n      answer = label(\"Nothing captured yet.\", 1f, false, Styles.captionColor, Position.START);\n      answer.testTag(\"device.answer\");\n      // Pinned below the scrolling body, so the answer stays in view (see Recipe.pin).\n      pin(answer);\n\n      row(body, 0xE8B6, \"Scan a barcode\", PlatformUtils.isSupported(Capability.BARCODE_SCAN),\n         () -> new BarcodePicker(\"Point at a barcode\")\n      {\n         protected void onBarcode(com.google.zxing.BarcodeFormat format, String code)\n         {\n            said(\"Barcode\", code);\n            hide();\n         }\n      }.show());\n\n      row(body, 0xE412, \"Take a photo\", hasCamera(),\n         () -> new PhotoPicker(PhotoPicker.Preview.PHOTO).show(a -> said(\"Photo\", a)));\n\n      row(body, 0xE55F, \"Pick a location\", hasLocation(),\n         () -> new LocationPicker(latlon -> said(\"Location\", latlon), 50.0, true, false, false)\n            .show());\n\n      // The device APIs that are not a picker. They belong on this screen for the same reason the\n      // three above do: every one of them is missing somewhere, and the web is the target where\n      // \"somewhere\" is decided by the browser the person happened to open, not by the build.\n      row(body, 0xE62E, \"Vibrate\", PlatformUtils.isSupported(Capability.VIBRATION),\n         () ->\n         {\n            Device.vibrate(300);\n            said(\"Vibrate\", \"asked for 300 ms\");\n         });\n\n      row(body, 0xE050, \"Speak a sentence\", PlatformUtils.isSupported(Capability.TEXT_TO_SPEECH),\n         () ->\n         {\n            // The voice is the device's (it follows Locale.getDefault()), so the sentence has to be in\n            // the device's language too: a Portuguese voice reading English sounds like letters\n            // read one by one, which is what a person heard on a pt-BR Windows (13/10/2026).\n            String sentence = sentenceInDeviceLanguage();\n            Device.textToVoice(sentence);\n            said(\"Speak\", sentence);\n         });\n\n      row(body, 0xE029, \"Dictate\", PlatformUtils.isSupported(Capability.SPEECH_TO_TEXT),\n         () -> Device.voiceToText(new Device.VoiceToTextListener()\n      {\n         public void onStart()\n         {\n            said(\"Dictate\", \"listening…\");\n         }\n\n         public void onResult(String text)\n         {\n            said(\"Dictate\", text);\n         }\n\n         public void onError(int code, String message)\n         {\n            // The browser's own error name comes through here: \"not-allowed\" is a refused\n            // microphone and \"no-speech\" is silence, and a person needs to tell those apart.\n            said(\"Dictate\", \"failed: \" + message);\n         }\n\n         public void onFinish()\n         {\n         }\n      }));\n\n      row(body, 0xE1A4, \"Read the battery\", PlatformUtils.isSupported(Capability.BATTERY),\n         () ->\n         {\n            int pct = PlatformUtils.instance.getRemainingBattery();\n            // -1 here does not mean \"no battery API\" — the capability already answered that. It\n            // means the browser has not resolved its promise yet, which is a real state in the\n            // first moments after startup and worth saying rather than printing \"-1%\".\n            said(\"Battery\", pct < 0 ? \"not known yet\" : pct + \"%\");\n         });\n\n      row(body, 0xE1BA, \"Network state\", PlatformUtils.isSupported(Capability.WIRELESS_STATE),\n         () -> said(\"Network\", PlatformUtils.instance\n            .radiodeviceGetState(UIConstants.WirelessType.WIFI)));\n\n      // The odd one out, and deliberately so: setScreenRotation is implemented on the web and\n      // SCREEN_ROTATION is NOT declared there, because the browser keeps an orientation lock only\n      // while the document is fullscreen. So this row offers what the platform declares, and on\n      // the web it is the row that stays disabled while the method behind it still exists.\n      // One row that is both the lock and the way back: its name says what the next tap does. Two\n      // rows (\"Lock\" and \"Unlock\") left the screen saying both at once (iPhone, 19/10/2026).\n      Label[] rotationName = new Label[1];\n      rotationName[0] = row(body, 0xE1C1, \"Lock to portrait\",\n         PlatformUtils.isSupported(Capability.SCREEN_ROTATION), () ->\n         {\n            portraitLocked = !portraitLocked;\n            Screen.setRotation(portraitLocked ? UIConstants.ScreenRotation.PORTRAIT\n               : UIConstants.ScreenRotation.ANY);\n            rotationName[0].setText(portraitLocked ? \"Unlock rotation\" : \"Lock to portrait\");\n            rotationName[0].invalidateMeasure();\n            said(\"Rotation\", portraitLocked ? \"locked to portrait\" : \"any orientation\");\n         });\n   }\n\n   private boolean portraitLocked;\n\n   /**\n    * Whether this platform can drive a camera.\n    * <p>\n    * This used to read {@code Device.platform == ANDROID || == IPHONE}, and that was wrong twice\n    * over: the desktop has had a webcam backend all along, and the web build gets the browser's\n    * camera through {@code getUserMedia} — so the recipe disabled two buttons that worked. Asking\n    * the capability instead of the platform is the whole point of the screen, and now the screen\n    * does it.\n    */\n   private static boolean hasCamera()\n   {\n      return PlatformUtils.isSupported(Capability.CAMERA);\n   }\n\n   /** Kept separate from the camera: a device may have one and not the other. */\n   private static boolean hasLocation()\n   {\n      return PlatformUtils.isSupported(Capability.LOCATION);\n   }\n\n   /** Adds one capability card; returns its name label, for a row whose name changes. */\n   private Label row(Component body, int icone, String name, boolean available, Runnable go)\n      throws Exception\n   {\n      Card c = new Card();\n      c.setLayout(new GridLayout(Position.START, Position.CENTER));\n      c.getStyle()\n         .outgap(Gap.ofFont(0, 0, 0, .5f));\n\n      // The whole recipe is \"ask before you offer\", so whether the device HAS the thing is the\n      // one fact on the card — and it was written in the same grey as everything else.\n      PalColor tom = available ? Tones.accent() : Tones.danger();\n\n      Component text = new Component();\n      text.setLayout(new VerticalLayout(Position.START, Position.START));\n      Label nameLabel = label(name, 1.2f, true, Styles.textColor, Position.START);\n      text.add(LAY_AUTO, nameLabel);\n      text.add(LAY_AUTO, label(available ? \"Ready\" : \"Not available on this device\", 1f, true,\n         tom, Position.START));\n\n      Button b = new Button(MaterialIcon.of(available ? 0xE5C8 : 0xE002, 1.4f));\n      b.setEnabled(available);\n      b.onAction(e -> go.run());\n      // \"device.take-a-photo\": a test reaches each row by what it does, not by its place in the list.\n      b.testTag(\"device.\" + name.toLowerCase(java.util.Locale.ROOT).replace(' ', '-'));\n\n      c.add(0f, 1f, 0f, Tones.tile(icone, tom), outgap(text, .6f, 0, 0, 0), b);\n      body.add(LAY_AUTO, c);\n      return nameLabel;\n   }\n\n   /** The sentence \"Speak\" says, in the language the device's voice speaks. */\n   private static String sentenceInDeviceLanguage()\n   {\n      switch (java.util.Locale.getDefault().getLanguage())\n      {\n         case \"pt\":\n            return \"O CrossFly roda em cinco plataformas.\";\n         case \"es\":\n            return \"CrossFly funciona en cinco plataformas.\";\n         case \"de\":\n            return \"CrossFly läuft auf fünf Plattformen.\";\n         case \"fr\":\n            return \"CrossFly fonctionne sur cinq plateformes.\";\n         default:\n            return \"CrossFly runs on five platforms.\";\n      }\n   }\n\n   /** One place for the answer, and it handles the cancel every one of them can return. */\n   private void said(String what, Object a)\n   {\n      answer.setText(a == null ? what + \": cancelled\" : what + \": \" + a);\n      // invalidateMeasure, not setValidLayout(false): the answer can grow from one line to two (an\n      // error message does), and only the climb to the root gives the body room for the second line.\n      // The bit alone re-laid the label inside the height it already had, and the second line was cut.\n      answer.invalidateMeasure();\n      Screen.repaint();\n   }\n}\n"
    }
  ],
  "lines": 244,
  "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/device",
  "run": "https://crossfly.dev/playground/index.html?ex=device",
  "runLocally": "gradlew lwjgl3:run --args=\"classname dev.cf.recipe.R21Device\"",
  "license": "See crossfly.dev for licensing. The code is published to be copied into your own project."
}