यह क्या दिखाता है
Choosing a file, sharing it, saving it and printing — and asking first whether this platform can.
These two are the clearest case in the framework of a capability that is not a property
of the platform. macOS has both since 29/09/2026, through AppKit's own share menu, and Windows 10+
since 01/10/2026, through WinRT's DataTransferManager; Linux has a file dialog and no share sheet.
The browser has a picker always and a share sheet only in
some browsers, over HTTPS. So "am I on the web?" is the wrong question, and
isSupported is the right one — it is answered at runtime, per capability,
and two browsers on the same machine can answer it differently.
The picker is asynchronous, and has to be: the web build is single-threaded JavaScript, so a blocking file dialog cannot exist on one of the four platforms. The callback arrives on the paint thread, so it may touch the UI directly, as it does below.
The cancel is not an error and not a null: open answers with an
empty array. That is one branch, always present, and forgetting it is what produces a
screen that appears to do nothing when the user backs out of the dialog.
अपनी मशीन पर चलाएँ
हर उदाहरण एक साधारण Window है, इसलिए लॉन्चर को सिर्फ़ उसका नाम चाहिए।
gradlew lwjgl3:run --args="classname dev.cf.recipe.R31Files"Java पहले से संकलित होता है, इसलिए ब्राउज़र आपके बदलाव को खुद दोबारा नहीं बना सकता — यहाँ दिखा कोड प्रकाशित होने से पहले TeaVM द्वारा JavaScript में बदला गया था। फ़ाइल को किसी प्रोजेक्ट में कॉपी कीजिए और वह चारों प्लेटफ़ॉर्म पर बिना बदलाव चलती है; ऊपर दी गई कमांड ही पूरी तैयारी है।
यह क्या इस्तेमाल करता है
इस उदाहरण में छुए गए फ़्रेमवर्क टाइप। जिनमें लिंक है उनका संदर्भ पृष्ठ मौजूद है।
सोर्स कोड
पूरा, बिना कटौती के। यह वही फ़ाइल है, उसका अंश नहीं।
package dev.cf.recipe;
import dev.cf.Tones;
import dev.crossfly.Capability;
import dev.crossfly.PlatformUtils;
import dev.crossfly.capability.Capabilities;
import dev.crossfly.capability.FileChooser;
import dev.crossfly.util.Debug;
import dev.crossfly.util.pdf.Pdf;
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.*;
/**
* Choosing a file, sharing it, saving it and printing — and asking first whether this platform can.
*
* <p>These two are the clearest case in the framework of a capability that is <b>not</b> a property
* of the platform. macOS has both since 29/09/2026, through AppKit's own share menu, and Windows 10+
* since 01/10/2026, through WinRT's DataTransferManager; Linux has a file dialog and no share sheet.
* The browser has a picker always and a share sheet only in
* some browsers, over HTTPS. So "am I on the web?" is the wrong question, and
* {@link PlatformUtils#isSupported} is the right one — it is answered at runtime, per capability,
* and two browsers on the same machine can answer it differently.</p>
*
* <p>The picker is <b>asynchronous, and has to be</b>: the web build is single-threaded JavaScript,
* so a blocking file dialog cannot exist on one of the four platforms. The callback arrives on the
* paint thread, so it may touch the UI directly, as it does below.</p>
*
* <p>The cancel is not an error and not a null: {@link FileChooser#open} answers with an
* <b>empty array</b>. That is one branch, always present, and forgetting it is what produces a
* screen that appears to do nothing when the user backs out of the dialog.</p>
*/
public class R31Files extends Recipe
{
private Label answer;
/** The last pick, kept so the share row has something to hand over. */
private FileChooser.PickedFile chosen;
public String title()
{
return "Files, sharing and printing";
}
public String note()
{
return "Ask the capability, not the platform. Phones, Windows 10+ and the Mac have the picker and"
+ " the share sheet; Linux has no share sheet; a browser has the picker always and the sheet only"
+ " sometimes."
+ " Printing takes a PDF, and every platform's own dialog does the rest.";
}
public void build(Component body) throws Exception
{
answer = label("Nothing chosen yet.", 1f, false, Styles.captionColor, Position.START);
answer.testTag("files.answer");
// Pinned below the scrolling body, so the answer stays in view (see Recipe.pin).
pin(answer);
row(body, "files.choose", 0xE2C8, "Choose a file", PlatformUtils.isSupported(Capability.FILE_PICKER),
() -> Capabilities.files()
.open(new FileChooser.Options().title("Choose a file").extensions("pdf", "png", "jpg", "txt"),
files ->
{
// The empty array IS the cancel. No null to check, and no exception to catch.
if (files.length == 0)
{
said("Cancelled");
return;
}
chosen = files[0];
read(chosen);
}));
row(body, "files.share-link", 0xE80D, "Share a link", PlatformUtils.isSupported(Capability.SHARING),
() -> Capabilities.sharing().text("CrossFly", "https://crossfly.dev"));
// Sharing.bytes and not Sharing.file, and the difference is the whole lesson: a file the
// user picked has no path on Android (it is a content:// document) and none at all in a
// browser, so the path-shaped call cannot serve the most ordinary flow there is — pick
// something, then send it. Handing over the content works on every platform that shares.
row(body, "files.share-file", 0xE2C4, "Share the chosen file",
PlatformUtils.isSupported(Capability.SHARING), () ->
{
if (chosen == null)
{
said("Choose a file first");
return;
}
try
{
Capabilities.sharing().bytes("CrossFly", chosen.name, chosen.bytes());
}
catch (Exception e)
{
said("Could not share: " + e.getMessage());
}
});
// Saving is NOT sharing, and the two rows sit together so the difference is visible: sharing
// sends content somewhere else and never says where it went; saving puts a file under the
// person's own control, with a name they picked, in a place they can find again.
row(body, "files.save", 0xE161, "Save a file",
PlatformUtils.isSupported(Capability.SAVE_FILE), () ->
{
byte[] content = chosen != null ? bytesOf(chosen)
: "Saved by CrossFly.\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String name = chosen != null ? chosen.name : "crossfly.txt";
String mime = chosen != null ? chosen.mimeType() : "text/plain";
if (content == null)
return;
Capabilities.saver()
.save(name, mime, content, (saved, error) -> said(saved ? "Saved " + name
: error == null ? "Cancelled" : "Could not save: " + error));
});
// Printing is the third way out, and it takes a PDF because every platform's print system
// already speaks PDF -- a receipt generated here and a boleto downloaded from a server
// print through the same call.
row(body, "files.print", 0xE8AD, "Print a receipt",
PlatformUtils.isSupported(Capability.PRINTING), this::printReceipt);
}
/**
* A receipt on an 80 mm roll, built with {@link Pdf} and handed to the system's print dialog.
* <p>
* The answer has <b>four</b> values and the browser is why: a phone and a desktop say whether
* the job was sent or cancelled, and a browser only says that its dialog closed. Treating
* {@code CLOSED} as either of the other two would be the app guessing.
*/
private void printReceipt()
{
Pdf pdf = new Pdf();
pdf.page(Pdf.ROLL_80MM_WIDTH, 300);
pdf.font(Pdf.COURIER_BOLD, 14).text(12, 270, "RECEIPT 4471");
// Amounts in reais on purpose: a Brazilian receipt, with the comma as decimal separator.
pdf.font(Pdf.COURIER, 10).text(12, 250, "2 x Specialty coffee R$ 38,00");
pdf.font(Pdf.COURIER, 10).text(12, 235, "1 x Sugar 1 kg R$ 6,90");
pdf.lineWidth(1).line(12, 225, 215, 225);
pdf.font(Pdf.COURIER_BOLD, 11).text(12, 208, "Total R$ 44,90");
Capabilities.printing().print("Receipt 4471",pdf.build(), (result, error) ->
{
switch (result)
{
case SENT:
said("Sent to the printer");
break;
case CANCELLED:
said("Cancelled");
break;
case CLOSED:
said("The print dialog closed (this platform does not say whether it printed)");
break;
default:
said("Could not print: " + error);
}
});
}
/**
* The chosen file's bytes, or null after saying why not.
*
* @param f the chosen file
* @return its content, or null
*/
private byte[] bytesOf(FileChooser.PickedFile f)
{
try
{
return f.bytes();
}
catch (Exception e)
{
said("Could not read " + f.name + ": " + e.getMessage());
return null;
}
}
/**
* Proves the pick is real by reading it, which is the part that differs most between platforms:
* a path on the desktop and on iOS, a content resolver on Android, an ArrayBuffer in a browser.
*/
private void read(FileChooser.PickedFile f)
{
try
{
int bytes = f.bytes().length;
said(f.name + " — " + bytes + " bytes read, " + f.mimeType());
}
catch (Exception e)
{
said(f.name + " — could not be read: " + e.getMessage());
}
}
/**
* A row per capability. The button carries a {@code testTag} so a TestCase can drive it — which
* on a phone is the only way in, since the whole UI is drawn on one GL surface and a device
* automation tool sees a single view with no children inside it.
*
* @param tag stable name for the button, for tests
* @param icone the Material glyph on the tile
* @param name the row's label
* @param available whether the platform has this capability
* @param go what the button does
*/
private void row(Component body, String tag, int icone, String name, boolean available,
Runnable go) throws Exception
{
Card c = new Card();
c.setLayout(new GridLayout(Position.START, Position.CENTER));
c.getStyle()
.outgap(Gap.ofFont(0, 0, 0, .5f));
PalColor tom = available ? Tones.accent() : Tones.danger();
Component text = new Component();
text.setLayout(new VerticalLayout(Position.START, Position.START));
text.add(LAY_AUTO, label(name, 1.2f, true, Styles.textColor, Position.START));
text.add(LAY_AUTO, label(available ? "Ready" : "Not on this platform", 1f, true, tom,
Position.START));
Button b = new Button(MaterialIcon.of(available ? 0xE5C8 : 0xE002, 1.4f));
b.setEnabled(available);
b.testTag(tag);
b.onAction(e -> go.run());
c.add(0f, 1f, 0f, Tones.tile(icone, tom), outgap(text, .6f, 0, 0, 0), b);
body.add(LAY_AUTO, c);
}
private void said(String what)
{
// Also to the log, and not only to the label. This screen is the one people drive by hand on
// a device or over ssh to find out whether a native capability actually worked -- and a
// verdict that exists only as pixels is unreadable exactly then. It cost a round trip here:
// a save that left no file on disk, with no way to tell a cancel from a failure.
Debug.log("[files] " + what);
answer.setText(what);
answer.invalidateMeasure(); // the pinned band must grow with it (see Recipe.pin)
Screen.repaint();
}
}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;
}
}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());
}
}इस पृष्ठ के मशीन-पठनीय रूप, ताकि मॉडल API का अनुमान लगाने के बजाय उदाहरण पढ़ सके।