Writing an adapter¶
An adapter runs Strata on an engine. It implements the SPI's Platform, with the clipboard, clock, text input sink and theme source a platform returns, and its Canvas. When it starts it hands core a Translator and a screen controller, then it creates a host from core and forwards the engine's frames and input to it. This page builds a complete adapter for an imaginary engine. Its code is compiled and passes Strata's contract tests as part of this site's build.
You need this only to bring Strata to a new engine: a UI toolkit such as JavaFX, LWJGL directly, libGDX, a game of your own, or an ImGui backend. To use Strata with an adapter that already exists, see the Platforms page instead.
What an adapter depends on¶
dependencies {
implementation 'org.inspirenxe:strata-api:0.1.0-SNAPSHOT'
implementation 'org.inspirenxe:strata-spi:0.1.0-SNAPSHOT'
implementation 'org.inspirenxe:strata-core:0.1.0-SNAPSHOT'
// The contract tests
testImplementation testFixtures('org.inspirenxe:strata-spi:0.1.0-SNAPSHOT')
}
core is the one place an adapter touches the implementation, through StrataCore. Screens written for your engine still compile against api only.
Rules for every adapter¶
- Call hosts on one thread, the engine's UI or render thread, and tell core which thread that is (see The UI thread). Strata does no locking.
- Coordinates are UI units with the origin at the top left of the host's area. Choose what a unit is, such as screen pixels or scaled pixels, and convert drawing and input the same way.
- Never let engine types into Strata calls. Map keys to
Key, buttons toMouseButton, modifiers toModifiers. - Tell hosts how many device pixels a unit covers whenever that changes (see Density).
- Run the contract tests.
Text metrics¶
TextMetrics measures text the way your engine draws it. Its requirements, from the Javadoc:
- A span drawn at x must end at x plus its measured width, give or take a unit of rounding. Text boxes place the caret from these numbers.
- The same span always gives the same result while
version()stays the same, with no side effects. - A style's
fontandsizemeasure as they are drawn. A family the engine does not know measures as the default font, and a size of 0 as the default size. Scale the font for other sizes, and draw it at that size so the glyphs stay sharp. version()changes whenever the same text could measure differently, such as when the application registers a font or the user changes font options. Hosts compare it at the start of each layout and lay every screen out again when it changed. The default is 0, for metrics that never change.- Width is 0 for an empty span and never negative.
- Appending a code point never makes a span narrower.
- Line height is positive.
ascent(style)is positive and no more than the line height. It is the distance from the top of a line to its baseline, the line the letters sit on, and panels line up the text of their children by it (VerticalAlignment.BASELINE). The default is four fifths of the line height, which suits most fonts; return the font's own ascent where the engine exposes it, so a font from a resource pack or a style that changes the size follows. The contract checks that it is positive and within the line.- Be fast, or cache: layout measures every label line, and a text box measures every prefix of its text.
import org.inspirenxe.strata.api.text.TextMetrics;
import org.inspirenxe.strata.api.text.TextSpan;
import org.inspirenxe.strata.api.text.TextStyle;
/**
* Text metrics for an engine with a fixed-width bitmap font: every code point is 6 units wide, one more when bold,
* and lines are 9 units tall, at the font's size of 9. The engine draws other sizes scaled, so they measure in
* proportion. It has one font, so a style's font changes nothing, as the SPI asks of a font the engine lacks.
*/
public final class MonospaceTextMetrics implements TextMetrics {
@Override
public float width(final TextSpan span) {
final int advance = span.style().bold() ? 7 : 6;
return span.text().codePointCount(0, span.text().length()) * (float) advance * scale(span.style());
}
@Override
public float lineHeight(final TextStyle style) {
return 9f * scale(style);
}
@Override
public float ascent(final TextStyle style) {
// This font puts its baseline 7 units below the top of a line. Panels line text up by it.
return 7f * scale(style);
}
/** Size 0 is the font's own size of 9; size 18 draws twice as large. */
private static float scale(final TextStyle style) {
return style.size() > 0f ? style.size() / 9f : 1f;
}
}
The platform¶
Platform is everything a host needs besides drawing:
import org.inspirenxe.strata.api.geometry.Rect;
import org.inspirenxe.strata.api.input.Modifier;
import org.inspirenxe.strata.api.text.TextMetrics;
import org.inspirenxe.strata.spi.Capabilities;
import org.inspirenxe.strata.spi.Clipboard;
import org.inspirenxe.strata.spi.Clock;
import org.inspirenxe.strata.spi.Conventions;
import org.inspirenxe.strata.spi.Platform;
import org.inspirenxe.strata.spi.TextInputSink;
import org.inspirenxe.strata.spi.ThemeSource;
import java.util.Optional;
/**
* The platform for the imaginary engine. It has an in-memory clipboard, needs no text input signal, and sends no
* key repeats, so Strata makes them.
*/
public final class ToyPlatform implements Platform {
// Constant for the platform's life, as the SPI requires
private static final Capabilities CAPABILITIES = new Capabilities(false, false);
private final TextMetrics metrics = new MonospaceTextMetrics();
private final ToyScheduler scheduler = new ToyScheduler();
private final Clipboard clipboard = new Clipboard() {
private String text;
@Override
public Optional<String> get() {
return Optional.ofNullable(this.text);
}
@Override
public void set(final String text) {
this.text = text;
}
};
private final TextInputSink textInput = new TextInputSink() {
@Override
public void start(final Rect caretArea) {
// An engine with an on-screen keyboard would open it here, near caretArea
}
@Override
public void stop() {
}
};
@Override
public TextMetrics textMetrics() {
return this.metrics;
}
@Override
public ToyScheduler scheduler() {
// The engine's main loop calls runDue every frame
return this.scheduler;
}
@Override
public Clipboard clipboard() {
return this.clipboard;
}
@Override
public Clock clock() {
return Clock.SYSTEM;
}
@Override
public TextInputSink textInput() {
return this.textInput;
}
@Override
public Capabilities capabilities() {
return CAPABILITIES;
}
@Override
public Conventions conventions() {
// Buttons act on release, shortcuts use Control and the engine binds nothing to Alt, so mnemonics stay on
return new Conventions(false, Modifier.CONTROL, true);
}
@Override
public ThemeSource themes() {
return ThemeSource.NONE;
}
}
| Method | Requirements |
|---|---|
textMetrics() |
Must match what the canvas's text draws |
scheduler() |
Runs the repeating tasks screens ask for, on the UI thread, first about one interval after every is called and then about one interval apart. It must never run a task after its subscription closed, even one already queued, must refuse an interval of 0 or less with IllegalArgumentException, and may round intervals to the engine's beat and drop runs it missed while busy. Every platform gives one. Wrap the engine's own scheduler where it has one; otherwise run due tasks from the engine's frame or tick, as ToyScheduler below does. |
clipboard() |
Never throws. A busy or missing clipboard reads as empty and ignores writes. Use Clipboard.NONE if the engine has none. Text written reads back unchanged until something else changes it. An engine that holds more than text also overrides getTransfer() and setTransfer(Transfer); see The clipboard. |
clock() |
Monotonic, never backward, advancing in real time. Clock.SYSTEM is System.nanoTime. Click counting, caret blinking and synthesized key repeat stall on a clock that stops. wall() returns the java.time.Clock that today is read from, such as the day a calendar marks; the default is the system clock in the default zone. Return the engine's clock or zone if it has one, or a fixed clock in tests. Core reads it at most about once a minute per control and never accepts null. |
textInput() |
Told when a text field gains and loses focus. Core calls start(area, kind), whose default forwards to start(area); for TextInputKind.SECRET, turn off input method composition, prediction and on-screen keyboard suggestions where the engine allows, and never log what is typed. start and stop may arrive repeatedly; a second start moves the input area and may change the kind. After start, the adapter must forward typed characters. Use TextInputSink.NONE if the engine always sends characters. |
capabilities() |
Constant for the platform's life. sendsKeyRepeat is true when the adapter delivers REPEAT itself. monospaceFont is true when monospaced styles get a font whose characters all have the same width; otherwise they use the normal font, measured and drawn alike. slicesSprites is true when the engine stores slicing with its images and applies it in sprite as well as nineSlice; the host then passes sized nine-slices and tiled sprites to the canvas whole. When it is false, the host draws them from spriteRegion calls, one per piece. Its default draws the whole image with sprite inside a clip; override it to draw part of an image directly, which avoids seams between pieces at scales that are not whole numbers. drawsShapes is true when the canvas implements every shape call; see Shapes. shapesRightToLeft is true when the engine orders and joins right-to-left text itself; see Right-to-left text. The two-, three- and four-argument constructors leave the later flags false. |
conventions() |
Optional. How the engine's own controls behave, for the behaviors a theme does not reach, so Strata's controls match them. Constant for the platform's life; Conventions.DEFAULT is (false, CONTROL, true) unless overridden. activateOnPress is true when the engine's buttons act as soon as the left pointer button goes down on them; buttons, cycle buttons, checkboxes, radio buttons, toggle switches and tabs then act on the press. When false, they act on the click, when the button comes back up still over them, and moving off first cancels. Keyboard activation and drop-down buttons do not change. shortcutModifier is the modifier that KeyStroke.shortcut strokes hold and that text fields use for copy, cut, paste, select all, undo and redo: Modifier.CONTROL on most platforms, Modifier.SUPER where shortcuts use Command, which the adapter must then send as Modifier.SUPER in its modifiers. An adapter that cannot tell reports Control. mnemonics is true when holding Alt underlines the marked letters of captions, Alt with a letter acts on the control that has it, and Alt or F10 alone moves to a menu bar. An engine that binds Alt for its own use reports false, and captions then show their text without the markers. Looks belong in the adapter's theme files, which can set the metric keys as well as paints, text styles and insets. |
themes() |
Where themes named by id come from; see Themes. Use ThemeSource.NONE if the engine has no themes or packs. |
causes() |
Optional. The engine's own cause stack, when it has one Strata's causes should follow; empty by default, and core then keeps its own, one per thread. A stack returned here keeps frames for the UI thread, closes them as CauseFrame states, and answers current() and context from the frames open at the call. Hosts push their input frames on it and developers get it from ComponentFactory.causes(). |
uiExecutor() |
Optional. The engine's executor for its UI thread; see The UI thread. |
uiThreadCheck() |
Optional. A cheap test that answers whether the calling thread is the UI thread; see The UI thread. |
fileDialogs() |
Optional. The engine's file dialogs as a FileDialogSource; see File dialogs. Empty by default, and core then reports file dialogs as not supported. |
user() |
Optional. The engine's object for the person at the screen, such as a user profile, which hosts put in every Origin.User so handlers can tell who acted. Empty by default, and the identity is then null. Return the same object every time. |
Return the same objects every time; hosts may keep what they get. Hosts may also share one platform: a screen stack hands its own to the host of every screen in it.
The scheduler¶
An engine with a main loop and no scheduler of its own can run screens' tasks from its frame:
import org.inspirenxe.strata.api.event.Subscription;
import org.inspirenxe.strata.spi.Scheduler;
import java.util.ArrayList;
import java.util.List;
/**
* Runs screens' repeating tasks for an engine whose main loop calls {@link #runDue} once per frame, on the main
* thread. An engine with a scheduler of its own wraps that instead.
*/
public final class ToyScheduler implements Scheduler {
private final List<Task> tasks = new ArrayList<>();
@Override
public Subscription every(final long millis, final Runnable task) {
if (millis <= 0L) {
throw new IllegalArgumentException("A task's interval must be above 0 ms: " + millis);
}
final Task scheduled = new Task(millis * 1_000_000L, System.nanoTime() + millis * 1_000_000L, task);
this.tasks.add(scheduled);
// Closing removes the task, so it never runs again
return () -> this.tasks.remove(scheduled);
}
/** Called by the engine once per frame, on the main thread. */
public void runDue() {
final long now = System.nanoTime();
// A task may close itself while it runs
for (final Task task : List.copyOf(this.tasks)) {
if (this.tasks.contains(task) && task.due <= now) {
// Runs missed while the engine was busy are dropped: the next one is an interval from now
task.due = Math.max(task.due + task.interval, now + 1L);
task.run.run();
}
}
}
private static final class Task {
private final long interval;
private final Runnable run;
private long due;
Task(final long interval, final long due, final Runnable run) {
this.interval = interval;
this.due = due;
this.run = run;
}
}
}
Core keeps the subscriptions and closes them when a screen closes, and checks before each run that the screen is still open, so a task never outlives its screen. The contract tests check the rest. Override passTime in your PlatformContract when the scheduler runs only when the engine drives it, to run the frame there.
Themes¶
ThemeSource only finds things. Core owns the theme file format, the parent chain, the layers and the merge, and the overrides code adds with Strata.themes(). Its methods:
| Method | Returns |
|---|---|
base(id) |
A theme the adapter builds in code, such as the engine's look drawn with its widget images. Core registers strata:flat itself. |
bases(id) |
The base files of a theme the adapter writes as JSON, lowest priority first. Empty by default. |
documents(id) |
Every theme file the engine's packs hold for the id, lowest priority first, each a ThemeDocument with its origin for messages, its JSON and its layer name |
defaultTheme() |
The theme screens open in when none is given, strata:flat by default |
version() |
A number that changes whenever any of the above may return something new |
This source gives the engine's look an id, reads theme files from the user's packs, and makes the engine's look the default:
import org.inspirenxe.strata.api.control.button.Button;
import org.inspirenxe.strata.api.geometry.Insets;
import org.inspirenxe.strata.api.theme.Paint;
import org.inspirenxe.strata.api.theme.SpriteId;
import org.inspirenxe.strata.api.theme.Theme;
import org.inspirenxe.strata.api.theme.ThemeId;
import org.inspirenxe.strata.api.theme.Themes;
import org.inspirenxe.strata.spi.ThemeDocument;
import org.inspirenxe.strata.spi.ThemeSource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
/**
* Themes for the imaginary engine. The engine's own look is a theme built in code from its widget images, and
* every resource pack the player turns on may hold a theme file for any id.
*/
public final class ToyThemeSource implements ThemeSource {
/** The engine's look, which screens open in when no theme is given. */
public static final ThemeId ENGINE = ThemeId.of("toy", "widgets");
private final Theme engine;
private final AtomicLong version = new AtomicLong();
// The engine loads packs on a thread of its own, so the list is replaced whole
private volatile List<Pack> packs = List.of();
public ToyThemeSource() {
// Every required key comes from the flat theme; the engine's images replace the button faces
final SpriteId button = SpriteId.of("toy", "widget/button");
this.engine = Themes.flat().toBuilder()
.paint(Button.STYLE_NORMAL, Paint.nineSlice(button, Insets.of(3f)))
.paint(Button.STYLE_HOVERED, new Paint.NineSlice(button, Insets.of(3f), 0xFFE0E0FF))
.build();
}
/**
* A resource pack: its id and the text of its theme files by theme id.
*
* @param id the pack's id, which names its layer
* @param themes the JSON of each theme file the pack holds
*/
public record Pack(String id, Map<ThemeId, String> themes) {
}
/** Called by the engine after it loads packs, the lowest priority first. */
public void reload(final List<Pack> packs) {
this.packs = List.copyOf(packs);
// Hosts compare the version on every layout and render, and resolve their themes again when it changes
this.version.incrementAndGet();
}
@Override
public Optional<Theme> base(final ThemeId id) {
// Core registers strata:flat itself; this source adds only the engine's theme
return id.equals(ToyThemeSource.ENGINE) ? Optional.of(this.engine) : Optional.empty();
}
@Override
public List<ThemeDocument> documents(final ThemeId id) {
// Lowest priority first, so a pack the player put above another wins per key
final List<ThemeDocument> documents = new ArrayList<>();
for (final Pack pack : this.packs) {
final String json = pack.themes().get(id);
if (json != null) {
// The origin names the file in messages; the layer is what Theme.layer reports for its keys
documents.add(new ThemeDocument(pack.id() + ":themes/" + id.path() + ".json", json, pack.id()));
}
}
return documents;
}
@Override
public ThemeId defaultTheme() {
return ToyThemeSource.ENGINE;
}
@Override
public long version() {
return this.version.get();
}
}
Never throw for an id you know nothing of; return empty. Leave out a file you cannot read. An id must not have both a base and base files, and core throws ThemeException when it resolves one that has. A base file that cannot be parsed is a bug in the adapter, so it makes the theme fail to resolve, while a broken document from a pack is logged and skipped. Core applies the documents in the order returned, so the adapter decides where the player's packs and each project's files fall. Keep version() in a volatile or atomic field, since the engine may reload on another thread, and keep it cheap, since hosts read it on every layout and render. StrataCore.resolveTheme(source, ref) resolves a ref once, which is handy in tests.
The UI thread¶
Components change on the UI thread, the one thread that calls the host. Core checks the thread of changes to attached components and hands work from other threads to it through UiThread (Strata.uiThread() and the factory's uiThread(); see Threading). It asks the platform for two things, and reads both once, on whichever thread first makes a host for the platform:
import org.inspirenxe.strata.api.text.TextMetrics;
import org.inspirenxe.strata.spi.Capabilities;
import org.inspirenxe.strata.spi.Clipboard;
import org.inspirenxe.strata.spi.Clock;
import org.inspirenxe.strata.spi.Conventions;
import org.inspirenxe.strata.spi.Platform;
import org.inspirenxe.strata.spi.Scheduler;
import org.inspirenxe.strata.spi.TextInputSink;
import org.inspirenxe.strata.spi.ThemeSource;
import java.util.Optional;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executor;
import java.util.function.BooleanSupplier;
/**
* The toy platform for an engine with a main thread that drains a task queue once per frame. It tells core which
* thread is the UI thread and how to hand work to it.
*/
public final class ToyMainThread implements Platform {
private final ToyPlatform toy = new ToyPlatform();
private final Thread mainThread;
private final Queue<Runnable> tasks = new ConcurrentLinkedQueue<>();
/**
* Creates the platform.
*
* @param mainThread the engine's main thread, the one that calls the host
*/
public ToyMainThread(final Thread mainThread) {
this.mainThread = mainThread;
}
/** Called by the engine at the start of every frame, on the main thread. */
public void runTasks() {
Runnable task;
while ((task = this.tasks.poll()) != null) {
task.run();
}
// Screens' repeating tasks run on the same beat
this.toy.scheduler().runDue();
}
@Override
public Optional<Executor> uiExecutor() {
// Accepts tasks from any thread and runs them in order. A task handed over on the main thread waits for the
// next runTasks, so it never runs inside a call into Strata.
return Optional.of(this.tasks::add);
}
@Override
public Optional<BooleanSupplier> uiThreadCheck() {
// Cheap, answers from any thread and never blocks
return Optional.of(() -> Thread.currentThread() == this.mainThread);
}
// The rest is the toy platform's
@Override
public TextMetrics textMetrics() {
return this.toy.textMetrics();
}
@Override
public Scheduler scheduler() {
return this.toy.scheduler();
}
@Override
public Clipboard clipboard() {
return this.toy.clipboard();
}
@Override
public Clock clock() {
return this.toy.clock();
}
@Override
public TextInputSink textInput() {
return this.toy.textInput();
}
@Override
public Capabilities capabilities() {
return this.toy.capabilities();
}
@Override
public Conventions conventions() {
return this.toy.conventions();
}
@Override
public ThemeSource themes() {
return this.toy.themes();
}
}
uiExecutor()accepts a task from any thread, runs tasks on the UI thread in the order given, and runs a task given on the UI thread after the current call into Strata returns, never inside it. Core wraps each task so that one that throws is logged. Without an executor, core queues the tasks and runs them at the start of the nextflush,layoutorrenderof any host of the platform, which suits every adapter that renders often. Supply one when work should run while nothing renders.uiThreadCheck()must be cheap, answer from any thread and not block, because core calls it on every change to an attached component. Without a test, core takes the first thread that dispatches input to, flushes, lays out or renders a host of the platform to be the UI thread, and until then treats every thread as passing the check and none as current.
A screen controller that is not a stack or host made by StrataCore must name its platform, so that Strata.uiThread() knows which platform's thread to use: call StrataCore.setScreenController(controller, platform) in place of the one-argument form, which finds the platform of a core stack or host by itself. StrataCore.clearScreenController() removes the controller and its platform, after which Strata.screens() and Strata.uiThread() throw IllegalStateException.
The clipboard¶
An engine whose clipboard holds only text implements get() and set(String). One that holds more also overrides the two transfer methods, which carry a Transfer: the same value that drags carry, with a value for each DataFormat.
setTransfer(Transfer)puts the formats the engine can hold on the clipboard, replacing what it held. Core never passes a local format, which is one that stays inside the process. Styled text may go out as its plain text. Failures are ignored, as forset.getTransfer()reads every format the clipboard holds that the engine can read. A value that is costly to read, such as an image, can be supplied withTransfer.Builder.supplyand read when a component asks.get()must still answer with the textsetTransferwrote. Core compares it with the text it saw after the write to tell whether the clipboard still holds what it set, and while it does, core hands back the whole transfer, local formats included. This is why an engine with a text-only clipboard keeps rows copied from a list for a paste in the same process.
Without the overrides, setTransfer puts the transfer's text on the clipboard with set, and getTransfer returns what get read as text.
File dialogs¶
fileDialogs() returns a FileDialogSource for an engine with native dialogs, such as java.awt.FileDialog on a desktop. Its four methods, open, openMany, save and folder, each take a FileDialog with the title, the start, the suggested name and the filters, and return a CompletableFuture.
- Core calls them on the UI thread, so return at once: show the dialog without blocking the thread, or on another thread, and complete the future when the user chooses.
- The future may complete on any thread. Core hands its result to the UI thread before the application sees it.
- Complete with an empty result when the user cancels, and exceptionally only when the dialog failed.
- Honor the title, start, filters and the suggested name where the engine can. Filters the engine cannot show may be left out.
Return Optional.empty() for an engine with no dialogs, the default. ComponentFactory.fileDialogs() then answers isSupported() false and completes every request empty. See File dialogs.
Starting up¶
Some things are shared by the whole process, so the adapter hands them to core once, when the engine loads it and before any project code can run:
import org.inspirenxe.strata.api.appearance.Appearance;
import org.inspirenxe.strata.api.screen.ScreenController;
import org.inspirenxe.strata.api.text.Text;
import org.inspirenxe.strata.api.text.TextSpan;
import org.inspirenxe.strata.api.text.TextStyle;
import org.inspirenxe.strata.core.StrataCore;
import org.inspirenxe.strata.spi.TextSource;
import org.inspirenxe.strata.spi.Translator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
/**
* What the imaginary engine's adapter does once, when the engine loads it and before any project code runs.
*/
public final class ToyStartup {
private ToyStartup() {
}
/**
* Hands core everything that is shared by the whole process.
*
* @param language the engine's current language
* @param screens what {@code Strata.screens()} opens screens with, such as a screen stack
*/
public static void start(final Language language, final ScreenController screens) {
// One translator for the process; Strata's own keys fall back to English where it has none
StrataCore.setTranslator(language);
// Strata.screens() returns this from now on
StrataCore.setScreenController(screens);
// Engine items show in every list with their name, beneath whatever projects register for them
StrataCore.registerDefaultAppearance(Item.class, Appearance.<Item>builder()
.name(item -> Text.translatable(item.nameKey()))
.build());
}
/**
* Wraps an engine message as Strata text, which reads the message anew each time a control lays out.
*
* @param message the message
* @return the text
*/
public static Text text(final Message message) {
return StrataCore.text(message);
}
/**
* The engine's language, as Strata's translator. Calls may come from any thread.
*/
public static final class Language implements Translator {
private final AtomicLong version = new AtomicLong();
private volatile Map<String, String> patterns = Map.of();
private volatile Locale locale = Locale.US;
/** Called by the engine when the player picks another language. */
public void change(final Locale locale, final Map<String, String> patterns) {
this.patterns = Map.copyOf(patterns);
this.locale = locale;
// Hosts lay everything out again, and dates and times follow the new locale
this.version.incrementAndGet();
}
@Override
public Optional<String> translate(final String key) {
// The pattern as written, with its %s and %1$s; core formats the arguments
return Optional.ofNullable(this.patterns.get(key));
}
@Override
public long version() {
return this.version.get();
}
@Override
public Locale locale() {
return this.locale;
}
}
/**
* An item of the engine's.
*
* @param nameKey the translation key of its name
*/
public record Item(String nameKey) {
}
/**
* A message in the engine's own text system: some text and an optional color.
*
* @param text the characters
* @param argb the color, or 0 to take the base's
*/
public record Message(String text, int argb) implements TextSource {
@Override
public List<TextSpan> resolve(final TextStyle base) {
// Parts with no style of their own take the base; a part that sets the color changes only that
return List.of(new TextSpan(this.text, this.argb == 0 ? base : base.withColor(this.argb)));
}
}
}
Translated text reads through one Translator for the whole process, which the adapter gives core with StrataCore.setTranslator. translate(key) returns the pattern for the current language as written, with its %s, %1$s and %%, or empty when there is none; core formats the arguments. version() changes whenever translations may read differently, and hosts lay everything out again when it does, so keep it cheap. Calls may come from any thread.
locale() returns the locale of the current language, which dates and times follow: month and weekday names, the first day of the week, the order of a date's parts and the 12 or 24 hour clock. Numbers follow it too, in the locale's digits and signs, for spinners and the number converters, and so do plural categories and the direction screens run in: a screen whose root sets none runs right to left when the language is written that way. The default is the JVM's default locale for formatting. Map the engine's language code to a Locale here, and change version() whenever the locale changes, since core caches what it reads per locale and version.
plural(long) gives the plural category of a count, which Text.plural picks its translation by (key.one, key.few, key.other and the like). The default applies the Unicode CLDR cardinal rules to locale(). Override it only when the engine's own text system has plural rules that Strata's text should agree with. Core calls it on every resolve, from any thread, and it must not return null.
An adapter gives its engine's types an appearance with StrataCore.registerDefaultAppearance(type, appearance) when it starts, so lists and images show them with icons, names and tooltips. Defaults sit beneath anything a project registers for the same type, whichever comes first.
An engine with its own text type can wrap it as Strata text with StrataCore.text(source). A TextSource resolves the engine text to spans over a base style each time a control lays out, so engine translations stay live, and every Text operation works on it. Parts with no style of their own take the base, and a part that sets a property changes only that property of the base. resolve may be called from any thread and must not change the engine text. Implement equals and hashCode by the engine text, as a record does, so equal engine texts make equal Strata text.
The controller handed to StrataCore.setScreenController is what Strata.screens() returns; Opening screens says what it must do. A controller that is not a core stack or host also names its platform, as The UI thread says.
The canvas¶
Canvas is what the engine draws for Strata:
import org.inspirenxe.strata.api.component.CursorKind;
import org.inspirenxe.strata.api.geometry.Insets;
import org.inspirenxe.strata.api.geometry.Rect;
import org.inspirenxe.strata.api.text.TextSpan;
import org.inspirenxe.strata.api.theme.SpriteId;
import org.inspirenxe.strata.api.visual.Visual;
import org.inspirenxe.strata.spi.Canvas;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
/**
* A canvas for an imaginary engine that takes a list of draw commands each frame. It keeps its own transform
* and clip stacks, since the commands use absolute coordinates.
*/
public class RecordingCanvas implements Canvas {
private final List<String> commands = new ArrayList<>();
private final Deque<float[]> transforms = new ArrayDeque<>();
private final Deque<Rect> clips = new ArrayDeque<>();
private final Deque<Float> opacities = new ArrayDeque<>();
private float dx;
private float dy;
private float scale = 1f;
private float opacity = 1f;
private CursorKind cursor = CursorKind.ARROW;
/** Starts a frame. A frame without a cursor request shows the default cursor. */
public void begin() {
this.commands.clear();
this.cursor = CursorKind.ARROW;
this.opacities.clear();
this.opacity = 1f;
}
/** Returns this frame's commands, for the engine to draw in order. */
public List<String> commands() {
return this.commands;
}
/** Returns the cursor to show after this frame. */
public CursorKind cursor() {
return this.cursor;
}
@Override
public void fill(final Rect rect, final int argb) {
if ((argb >>> 24) != 0) {
this.commands.add("fill " + this.place(rect) + " " + Integer.toHexString(this.faded(argb)));
}
}
@Override
public void fillGradient(final Rect rect, final int topArgb, final int bottomArgb) {
this.commands.add("gradient " + this.place(rect) + " " + Integer.toHexString(this.faded(topArgb)) + " "
+ Integer.toHexString(this.faded(bottomArgb)));
}
@Override
public void sprite(final SpriteId sprite, final Rect rect, final int tint) {
// An engine without the image would draw a placeholder here, never throw
this.commands.add("sprite " + sprite + " " + this.place(rect));
}
@Override
public void nineSlice(final SpriteId sprite, final Rect rect, final Insets border, final int tint) {
this.commands.add("nineslice " + sprite + " " + this.place(rect) + " " + border);
}
@Override
public void text(final TextSpan span, final float x, final float y) {
this.commands.add("text '" + span.text() + "' at " + (x * this.scale + this.dx) + "," + (y * this.scale + this.dy)
+ (this.scale == 1f ? "" : " scaled " + this.scale));
}
@Override
public void pushClip(final Rect rect) {
// A clip applies in the current transform and intersects the clip already in place
final Rect placed = this.place(rect);
this.clips.push(this.clips.isEmpty() ? placed : this.clips.peek().intersect(placed));
this.commands.add("clip " + this.clips.peek());
}
@Override
public void popClip() {
this.clips.pop();
this.commands.add(this.clips.isEmpty() ? "unclip" : "clip " + this.clips.peek());
}
@Override
public void pushTranslation(final float x, final float y) {
this.transforms.push(new float[] {this.dx, this.dy, this.scale});
this.dx += x * this.scale;
this.dy += y * this.scale;
}
@Override
public void pushScale(final float factor) {
this.transforms.push(new float[] {this.dx, this.dy, this.scale});
this.scale *= factor;
}
@Override
public void popTransform() {
final float[] previous = this.transforms.pop();
this.dx = previous[0];
this.dy = previous[1];
this.scale = previous[2];
}
@Override
public void pushOpacity(final float factor) {
// Factors multiply when nested. This engine fades by scaling the alpha of the colors it is given.
this.opacities.push(this.opacity);
this.opacity *= factor;
}
@Override
public void popOpacity() {
this.opacity = this.opacities.pop();
}
@Override
public boolean showsCursor(final CursorKind cursor) {
// The default accepts the original eight kinds. This engine also has a busy cursor; the host asks for the
// fallback of every kind this returns false for.
return cursor == CursorKind.WAIT || Canvas.super.showsCursor(cursor);
}
@Override
public void visual(final Visual visual, final Rect rect, final int tint) {
// This engine knows no visuals of its own, so every one gets the placeholder
this.commands.add("placeholder " + this.place(rect));
}
@Override
public void nextLayer() {
this.commands.add("layer");
}
@Override
public void requestCursor(final CursorKind cursor) {
this.cursor = cursor;
}
/** Adds a command to this frame's list. */
protected final void record(final String command) {
this.commands.add(command);
}
/** Returns a color with the current opacity multiplied into its alpha. */
protected final int faded(final int argb) {
final int alpha = Math.round((argb >>> 24) * this.opacity);
return (alpha << 24) | (argb & 0xFFFFFF);
}
/** Returns the horizontal position in the engine's pixels. */
protected final float placeX(final float x) {
return x * this.scale + this.dx;
}
/** Returns the vertical position in the engine's pixels. */
protected final float placeY(final float y) {
return y * this.scale + this.dy;
}
/** Returns the current scale, which sizes in UI units are multiplied by. */
protected final float currentScale() {
return this.scale;
}
protected final Rect place(final Rect rect) {
return new Rect(rect.x() * this.scale + this.dx, rect.y() * this.scale + this.dy,
rect.width() * this.scale, rect.height() * this.scale);
}
}
Requirements:
- Coordinates are UI units in the current transform. Round to the engine's pixel grid if it needs whole pixels, the same way for drawing and clipping.
- Colors are ARGB. Alpha 0 draws nothing; partial alpha blends over what is already drawn.
- Later calls draw over earlier ones. An engine that batches or sorts must keep this order, at least across
nextLayer(). - Calls may be recorded and replayed later. Nothing requires immediate drawing.
- Clips and transforms are stacks. Strata balances every push with a pop within a frame, in reverse order, even when a component throws. A clip applies in the transform current when it was pushed and intersects the clip already in place.
- Transforms are translations and uniform scales. A scale multiplies every later coordinate and size about the current origin, translations pushed inside it included, and text drawn while scaled comes out at the scaled size. Labels use it for their text scale.
- Never throw for missing resources. Draw something visible instead, so the problem shows on screen.
visualdraws the visuals your adapter makes, such as images or engine objects, and a placeholder for any other. Offer a public class that makes them, asSwingVisualsdoes, so code with an engine object in hand can show it through the API.- A nine-slice smaller than its borders shrinks the borders rather than drawing outside its rectangle. An engine that knows a sprite's slicing natively may ignore the
borderargument. textputs the top-left of the line box at the point, not the baseline, applies every style the engine supports, and lines up with the platform's text metrics.requestCursoris called at most once per frame, after drawing, while the pointer is over a component or a drag carries a transfer. In a frame without a request, show the default cursor. See Cursors.- A sprite id names one sprite, which an adapter may hold in several densities; see Density.
textnever gets a span that holds a picture. Core measures pictures itself and draws them withspriteandvisual, so the engine's metrics and canvas see only characters. Draw monospaced spans in the engine's monospaced font whenCapabilities.monospaceFontis true.spriteRegiondraws part of an image, given in fractions of the whole from 0 to 1. Core draws sized nine-slices and tiled sprites from it whenCapabilities.slicesSpritesis false. Its default draws the whole image inside a clip; override it when the engine can draw part of an image directly, or pieces drawn side by side may show seams at scales that are not whole numbers.
Cursors¶
showsCursor(kind) says whether the canvas shows a CursorKind as itself. The host asks it before requestCursor, and asks for the kind's fallback() when the answer is false, walking down to ARROW, so every kind shows something close on every engine. requestCursor itself must accept every kind without throwing, and showsCursor must answer true for ARROW.
| Kind | Fallback |
|---|---|
RESIZE_DIAGONAL_DOWN, RESIZE_DIAGONAL_UP |
RESIZE_ALL |
GRAB, GRABBING |
HAND |
NO_DROP |
NOT_ALLOWED |
The others, WAIT, PROGRESS, COPY and HELP among them |
ARROW |
The default showsCursor accepts the eight kinds Strata started with: ARROW, TEXT, HAND, CROSSHAIR, RESIZE_HORIZONTAL, RESIZE_VERTICAL, RESIZE_ALL and NOT_ALLOWED. Override it, as the example canvas does for WAIT, to add the kinds the engine has.
Density¶
The host's area is in UI units, and an adapter chooses what a unit is. Components and painters also need to know how many device pixels a unit covers, so a hairline can stay one device pixel wide and a chart can snap to the pixel grid. Call setPixelsPerUnit(density) on the host or the stack with resize, whenever either changes: when the window moves to another display, the user zooms, or a game's GUI scale changes. The density is 1 until set, and a change lays every component out again. Pass the device pixels per unit: a game adapter its GUI scale times the window's content scale, unless the framebuffer already holds the display's own pixels, and a desktop adapter its zoom times the display's scale. It must be finite and greater than 0.
/** Tells Strata the density when the window moves to another display or the user zooms. */
public void onScaleChanged(final int widthPixels, final int heightPixels, final float displayScale) {
// Device pixels per unit: this adapter's zoom times what the display adds on top
this.screens.setPixelsPerUnit(this.scale * displayScale);
// The area in units changes with the density, so it is set again beside it
this.screens.resize(widthPixels / this.scale, heightPixels / this.scale);
}
The canvas draws the densest image the device pixels under a rectangle can show. An adapter that keeps a sprite in several densities, such as @2x files, picks the denser one at 2 device pixels per unit and above, and draws it at the same size in UI units, so a sprite stays sharp. A sized nine-slice's border is given in units and keeps its size.
Opacity¶
pushOpacity(factor) multiplies the alpha of every later color and tint by a factor, from 0 to 1 exclusive, until the matching popOpacity(). Factors multiply when nested. Core pushes one around each component whose opacity is between 0 and 1, so a fade covers everything the component and its children draw: fills, gradients, sprites, nine-slices, text and visuals. Both methods do nothing by default, so a canvas that does not override them draws faded components opaque and a fade shows as a jump at its end. An engine that can fade only some kinds of draw says so in its canvas documentation. The example canvas multiplies the factor into the alpha of the colors it records.
Shapes¶
Lines, polylines, outlines, rounded rectangles, ellipses, arcs, shapes, gradients at an angle and shadows are optional. Report Capabilities.drawsShapes and implement every call:
import org.inspirenxe.strata.api.extension.Gradient;
import org.inspirenxe.strata.api.extension.Shape;
import org.inspirenxe.strata.api.extension.Stroke;
import org.inspirenxe.strata.api.geometry.Rect;
import org.inspirenxe.strata.api.visual.PixelBuffer;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.IdentityHashMap;
import java.util.Map;
/**
* The recording canvas for an engine that can draw shapes. An adapter whose platform reports
* {@code Capabilities.drawsShapes} implements every call below, and core stops drawing shapes from fills.
*/
public final class RecordingShapesCanvas extends RecordingCanvas {
private final Deque<Boolean> smoothing = new ArrayDeque<>();
// The engine's copy of each pixel buffer drawn, as a texture would be, and the version it was copied at
private final Map<PixelBuffer, Long> copies = new IdentityHashMap<>();
private boolean smooth = true;
@Override
public void pushSmoothing(final boolean smoothed) {
this.smoothing.push(this.smooth);
this.smooth = smoothed;
}
@Override
public void popSmoothing() {
this.smooth = this.smoothing.pop();
}
@Override
public void line(final float x1, final float y1, final float x2, final float y2, final Stroke stroke,
final int argb) {
this.record("line " + this.placeX(x1) + "," + this.placeY(y1) + " to " + this.placeX(x2) + ","
+ this.placeY(y2) + this.pen(stroke, argb));
}
@Override
public void polyline(final float[] points, final int count, final Stroke stroke, final int argb) {
// The array is only valid during this call, so everything needed is read now
final StringBuilder through = new StringBuilder();
for (int i = 0; i < count; i++) {
through.append(' ').append(this.placeX(points[i * 2])).append(',').append(this.placeY(points[i * 2 + 1]));
}
this.record("polyline" + through + this.pen(stroke, argb));
}
@Override
public void fillRoundRect(final Rect rect, final float radius, final int argb) {
this.record("roundrect " + this.place(rect) + " r" + radius * this.currentScale() + this.shade(argb));
}
@Override
public void strokeRect(final Rect rect, final float radius, final Stroke stroke, final int argb) {
// The outline lies inside the rectangle
this.record("strokerect " + this.place(rect) + " r" + radius * this.currentScale() + this.pen(stroke, argb));
}
@Override
public void fillEllipse(final Rect oval, final int argb) {
this.record("ellipse " + this.place(oval) + this.shade(argb));
}
@Override
public void strokeEllipse(final Rect oval, final Stroke stroke, final int argb) {
this.record("strokeellipse " + this.place(oval) + this.pen(stroke, argb));
}
@Override
public void strokeArc(final Rect oval, final float startRadians, final float sweepRadians, final Stroke stroke,
final int argb) {
// Angles turn clockwise on screen from the right of the oval, in radians
this.record("arc " + this.place(oval) + " from " + startRadians + " by " + sweepRadians + this.pen(stroke, argb));
}
@Override
public void fillShape(final Shape shape, final int argb) {
// Shapes never change, so an engine can keep its own copy of each one, found by identity
this.record("shape " + System.identityHashCode(shape) + this.shade(argb));
}
@Override
public void strokeShape(final Shape shape, final Stroke stroke, final int argb) {
this.record("strokeshape " + System.identityHashCode(shape) + this.pen(stroke, argb));
}
@Override
public void fillGradient(final Rect rect, final Gradient gradient) {
this.record("anglegradient " + this.place(rect) + " " + gradient.angleRadians() + " " + gradient.stops().size()
+ " stops");
}
@Override
public void shadow(final Rect rect, final float radius, final float blur, final int argb) {
this.record("shadow " + this.place(rect) + " blur " + blur * this.currentScale() + this.shade(argb));
}
@Override
public void pushRoundClip(final Rect rect, final float radius) {
// Popped by popClip, like any clip
this.pushClip(rect);
this.record("roundclip r" + radius * this.currentScale());
}
@Override
public void pixels(final PixelBuffer buffer, final Rect rect, final int tint) {
// Copy the pixels again only when the buffer's version moved
final Long copied = this.copies.put(buffer, buffer.version());
this.record((copied == null || copied != buffer.version() ? "upload " : "reuse ") + this.place(rect));
}
private String shade(final int argb) {
return " " + Integer.toHexString(this.faded(argb)) + (this.smooth ? "" : " hard");
}
private String pen(final Stroke stroke, final int argb) {
return " width " + stroke.width() * this.currentScale() + " " + stroke.cap() + " " + stroke.join()
+ this.shade(argb);
}
}
When drawsShapes is false, core never calls them. It draws each shape from fill and vertical fillGradient calls one device pixel high, a gradient at an angle from columns of vertical gradients, a shadow from a few rings of falling alpha and a round clip as a clip to the whole rectangle. That works on every canvas and looks stepped.
When it is true, the defaults throw UnsupportedOperationException, so implement all of these. What core passes:
- Radii are limited to half the shorter side of their rectangle and are not negative, and a stroke of width 0 is not drawn.
- Angles are radians, 0 at the right of the oval, turning clockwise on screen. A sweep is less than a full turn either way; core draws a full turn with
strokeEllipse. polylinegets a count of at least 2, and the array is valid only during the call.shadowgets a blur greater than 0; a hard shadow comes asfillRoundRect.- The outline of a rectangle or an oval lies inside it, so the stroke's outer edge is the shape's edge. Lines and shapes are centered on their path, with the stroke's joins at corners and its caps on the ends of open subpaths. Fills use the nonzero winding rule, and every subpath is closed.
- Shapes never change. Keep the engine's own copy of a
Shapeby identity. pushSmoothing(boolean)andpopSmoothing()nest like clips, and shapes are anti-aliased outside any push. Shapes must obey them, and sprites and text may.pushRoundClip(rect, radius)is popped bypopClip()like a rectangle clip. Its default clips to the whole rectangle. An engine whose clips are hard-edged may keep them so.
Pixel buffers¶
pixels(buffer, rect, tint) draws a PixelBuffer, the pixels an Image shows after setPixels. Keep one engine copy of each buffer drawn, such as a texture, copy the pixels again only when the buffer's version() differs from the one copied, and drop copies of buffers no longer drawn. Draw with nearest sampling so pixel art stays sharp, unless pushSampling(Sampling.LINEAR) is in effect. Core calls it on every canvas; the default draws the placeholder of a visual the canvas did not make.
Sampling and frosted glass¶
pushSampling(sampling) and popSampling() say how sprite, spriteRegion, nineSlice and pixels sample their images, NEAREST outside any push. Core balances them as it does clips. The defaults do nothing, for an engine that samples its own way.
backdrop(rect, radius, blur, tint) draws frosted glass: what the engine has behind the rectangle blurred, the tint over it, cut to rounded corners, under the current clip and opacity. Core calls it only when the platform reports Capabilities.blursBackdrop, with the radius already limited; otherwise it fills a rounded rectangle with the tint. What is behind is yours to define. Swing and Vulkan blur an image the application gives them, since neither can read back what it drew in the frame; an engine that can read its target may blur that. A canvas with nothing to blur at the time fills the tint.
Right-to-left text¶
Core runs the bidirectional algorithm on every line and places the runs in visual order. What the canvas receives depends on Capabilities.shapesRightToLeft:
- When it is false, which is the default, core passes each right-to-left run to
text(span, x, y)with its characters in reverse order and its brackets mirrored. That reads correctly for Hebrew and draws Arabic with its letters unjoined, which is what an engine that draws one code point at a time can do. - When it is true, core passes each right-to-left run to
text(span, x, y, rightToLeft)withrightToLefttrue and the characters in logical order. The engine must order and join the run and draw it with its left edge atx, as wide as the platform'sTextMetrics.widthmeasures the span. Left-to-right runs still come through the three-argument form.
Driving the host¶
Create a host with StrataCore:
| Factory | For |
|---|---|
createScreenHost(platform, theme) |
One screen at a time. A screen that asks to close is closed by the host. |
createScreenHost(platform, theme, closeRequested) |
The same, but you decide what closing means, such as closing the engine screen that holds the host. |
createScreenStack(platform, theme) |
A stack of screens, for engines with no screen system |
Give the host or the stack setSpriteSizes(sizes), a SpriteSizes that returns each sprite's size in UI units, the size it is drawn at by default, or empty when the engine does not know it. Images showing a sprite take that size until one is set, and 16 by 16 without it. Give it again after the sprites reload, which lays the screens out again.
createScreenHost(platform, theme, closeRequested) and createScreenStack(platform, theme) also take a ThemeRef. A host given a ref also compares the version of the overrides code adds with Strata.themes(), which core keeps for every engine, and resolves the ref again when it changes. StrataCore.resolveTheme resolves a ref once, without a host.
Input¶
ScreenHost and ScreenStack are both an InputSink, which takes the engine's input in UI units and Strata's types. Each method but pointerLeft returns true when Strata used the input; pass the rest on to the engine.
| Method | Send | Returns true when |
|---|---|---|
pointerMoved(x, y, modifiers) |
Every pointer movement, during drags too | The pointer is over Strata's UI or dragging in it |
pointerButton(x, y, button, pressed, modifiers) |
Each button's press and release | A component received the press, or the release ended a press that started on one |
scroll(x, y, scrollX, scrollY, kind, modifiers) |
Wheel and trackpad scrolling, in notches | A component consumed the scroll |
pointerLeft() |
The pointer leaving the host's area | |
activeChanged(active) |
The window or area losing or regaining the keyboard. Focus leaves while inactive and comes back after. | |
key(key, action, modifiers) |
Key presses, repeats and releases | A component consumed the key, or Tab moved focus |
charTyped(codePoint, modifiers) |
Typed characters, never control characters | The focused component consumed it |
Send Command as Modifier.SUPER, and the key that opens a context menu as Key.MENU. Each method also has a form that takes an InputContext, for an engine that tells devices apart; see Devices. Drags and drops from other applications use four more methods; see Drops from other applications.
Then forward the engine's callbacks:
import org.inspirenxe.strata.api.input.Key;
import org.inspirenxe.strata.api.input.KeyAction;
import org.inspirenxe.strata.api.input.Modifier;
import org.inspirenxe.strata.api.input.Modifiers;
import org.inspirenxe.strata.api.input.MouseButton;
import org.inspirenxe.strata.api.input.ScrollKind;
import org.inspirenxe.strata.api.screen.Screen;
import org.inspirenxe.strata.api.theme.Theme;
import org.inspirenxe.strata.core.StrataCore;
import org.inspirenxe.strata.spi.ScreenStack;
import java.util.List;
/**
* Glue between the imaginary engine's callbacks and a Strata screen stack. The engine works in pixels; this
* adapter uses one UI unit per {@code scale} pixels and converts both drawing and input by the same factor.
*/
public final class ToyAdapter {
private final ScreenStack screens;
private final RecordingCanvas canvas;
private final float scale;
public ToyAdapter(final Theme theme, final float scale) {
this.screens = StrataCore.createScreenStack(new ToyPlatform(), theme);
this.canvas = new RecordingCanvas();
this.scale = scale;
}
public void open(final Screen screen) {
this.screens.push(screen);
}
// Engine callbacks. All of them arrive on the engine's UI thread.
public void onResize(final int widthPixels, final int heightPixels) {
this.screens.resize(widthPixels / this.scale, heightPixels / this.scale);
}
/** Called once per frame. Returns the commands to draw, in order. */
public List<String> onFrame() {
this.canvas.begin();
this.screens.render(this.canvas);
return this.canvas.commands();
}
/** Returns true when Strata used the input, so the engine should not act on it too. */
public boolean onMouseMove(final int x, final int y) {
return this.screens.pointerMoved(x / this.scale, y / this.scale, Modifiers.NONE);
}
public boolean onMouseButton(final int x, final int y, final int engineButton, final boolean down,
final int engineMods) {
return this.screens.pointerButton(x / this.scale, y / this.scale, button(engineButton), down,
modifiers(engineMods));
}
public boolean onWheel(final int x, final int y, final int notchesTowardUser) {
// Strata counts a wheel turned away from the user as positive
return this.screens.scroll(x / this.scale, y / this.scale, 0f, -notchesTowardUser, ScrollKind.WHEEL,
Modifiers.NONE);
}
public void onMouseLeft() {
this.screens.pointerLeft();
}
public boolean onKey(final int engineKey, final boolean down, final int engineMods) {
// This engine sends no repeats (see ToyPlatform), so every down is a press
return this.screens.key(key(engineKey), down ? KeyAction.PRESS : KeyAction.RELEASE, modifiers(engineMods));
}
public boolean onChar(final int codePoint) {
// Characters arrive separately from keys, after the keyboard layout; control characters are not sent
if (Character.isISOControl(codePoint)) {
return false;
}
return this.screens.charTyped(codePoint, Modifiers.NONE);
}
// Mapping the engine's numbers to Strata's types. No engine number ever reaches a component.
private static MouseButton button(final int engineButton) {
return switch (engineButton) {
case 0 -> MouseButton.LEFT;
case 1 -> MouseButton.RIGHT;
case 2 -> MouseButton.MIDDLE;
default -> MouseButton.UNKNOWN;
};
}
private static Modifiers modifiers(final int engineMods) {
Modifiers modifiers = Modifiers.NONE;
if ((engineMods & 1) != 0) {
modifiers = modifiers.with(Modifier.SHIFT);
}
if ((engineMods & 2) != 0) {
modifiers = modifiers.with(Modifier.CONTROL);
}
if ((engineMods & 4) != 0) {
modifiers = modifiers.with(Modifier.ALT);
}
return modifiers;
}
private static Key key(final int engineKey) {
return switch (engineKey) {
case 8 -> Key.BACKSPACE;
case 9 -> Key.TAB;
case 13 -> Key.ENTER;
case 27 -> Key.ESCAPE;
case 37 -> Key.LEFT;
case 39 -> Key.RIGHT;
default -> engineKey >= 'A' && engineKey <= 'Z' ? Key.valueOf(String.valueOf((char) engineKey)) : Key.UNKNOWN;
};
}
}
The host's duties for you:
- Call
resizebefore the firstrender, and again whenever the area changes. CallsetPixelsPerUnitbeside it whenever the density changes; see Density. - Call
renderonce per frame. It builds pending screens, synthesizes key repeats if needed, runs layout, draws and requests the cursor. - Send every pointer movement, during drags too: drags are recognized from moves.
- Send each button's press and release. A release with no matching press is ignored.
- Send keys with the right
KeyAction. If the engine reports repeats as more presses, useHeldKeysto turn the second and later presses intoREPEAT, and callHeldKeys.clear()when the window loses focus. - Send typed characters separately through
charTyped, as code points, after the keyboard layout and input method. Never send control characters. - Call
pointerLeft()when the pointer leaves the area, andclose()when the engine screen goes away so the Strata screen'sclosed()runs. - When the user asks to close the engine screen, with its close button or a key it closes screens with, call
requestClose()first and close the engine screen only when it returns true. A screen with unsaved changes can refuse.close()never asks, so use it for closes the engine makes for its own reasons. - Show
title()if the engine shows a title, and compare it with the one shown on each frame, since the screen can change it withScreenHandle.setTitle. The text is returned as it is, so resolve it again when translations change. - A toolkit that asks an embedded widget for its preferred size, such as Swing's
pack(), can askpreferredSize(availableWidth, availableHeight), in UI units with infinity for no limit. It builds a pending screen and measures its content without arranging it, and returnsSize.ZEROwhen no screen is open. An engine whose screens fill the window has no use for it. - Pass input on to the engine when the host returns false. See What reaches the engine.
- Use Strata's directions: vertical scroll is positive away from the user, horizontal scroll positive toward the left.
The example adapter's close button and title bar, which an engine with a window of its own would call:
/** The engine's window close button. The window closes only when this returns true. */
public boolean onCloseButton() {
return this.screens.requestClose();
}
/** What the engine shows in its title bar, compared with the one shown on each frame. */
public String windowTitle() {
return this.screens.title().plain();
}
Rendering on demand¶
An engine that draws every frame, as a game does, calls render every frame and can ignore this section. A toolkit that draws only when asked, such as Swing, can render on demand with these calls:
/** Wires on-demand drawing for an engine that repaints only when asked. */
public void drawOnDemand(final Runnable scheduleRedraw) {
// Runs once each time the stack goes from clean to dirty, on any thread, so it only schedules a redraw
this.screens.setFrameRequester(scheduleRedraw);
}
/**
* Whether the engine should draw now: something changed, or a timed change such as a caret blink is due, and one
* interval of the frame rate has passed since the last frame began.
*/
public boolean frameDue(final long nowNanos) {
final boolean wanted = this.screens.needsFrame() || nowNanos >= this.screens.nextFrameNanos();
return wanted && nowNanos >= this.lastFrame + this.screens.frameIntervalNanos();
}
/** Draws a frame, noting when it began. */
public void drawFrame(final Canvas canvas, final long nowNanos) {
this.lastFrame = nowNanos;
this.screens.render(canvas);
}
needsFrame()is true when something changed since the lastrender: input the host dispatched, a layout invalidation, a binding queued, a task handed to the UI thread, an overlay opening or closing, a change of look, or a screen opening or closing. A new host needs a frame. Any thread may call it.nextFrameNanos()is when the next timed change is due, as a reading of the platform's clock: a caret's next blink, a tooltip's delay running out, a toast's expiry, the next synthesized key repeat, a caption or progress bar that moves with the clock, or an animation's next step. It isLong.MAX_VALUEwhen nothing timed is waiting. Call it on the UI thread after a render. An adapter that renders on demand renders when the clock reaches it, even whenneedsFrame()is false, and uses the platform's clock, the one core reads.setFrameRequester(requester)sets what runs whenneedsFrame()turns from false to true, and runs it at once if a frame is already needed. It runs once per change from clean to dirty, not again before the nextrender, and it can run on any thread, such as the thread that set a state. It must only schedule a frame, for example by asking the engine to repaint, and must not call into the host.setFrameRate(framesPerSecond)sets how many frames a second the host asks for at most, from 1 to 1000, andframeIntervalNanos()is one second divided by it. It is 30 until set. A stack passes its rate to every screen it holds and to those pushed later. Any thread may set it.
An adapter renders when needsFrame() is true or the clock has reached nextFrameNanos(), and never sooner than frameIntervalNanos() after its last render began. The first frame after a host is made is always due. nextFrameNanos() itself is never sooner than one interval after the last render began, so a timed change due sooner waits for that frame, and something that moves with the clock, such as a sliding caption, an indeterminate progress bar or an animation, draws at the frame rate. Motion reads the clock when it draws, so a lower rate shows the same motion in fewer steps. A tool window is fine at 20 frames a second; set a higher rate for a screen that plays something smooth. An engine that draws every frame anyway is not held to the rate.
Devices¶
An engine that tells devices apart sends input through the forms that take an InputContext: the device (MOUSE, KEYBOARD, CONTROLLER, TOUCH, PEN or NARRATOR), which pointer of it, and how hard it presses, from 0 to 1. Use the same pointer id from a touch's press to its release, since each pointer id has its own presses, drags and click counts. A touch going down is a left button press. Only mouse and pen pointers hover, so a touch shows no hover face and no tooltip. A long press of a first touch opens the context menu, and two touches that move together scroll. A controller's buttons arrive as keys, with new InputContext(Device.CONTROLLER, 0, 1f).
/** A touch going down. A touch is a left button press from a device that does not hover. */
public boolean onTouchDown(final int x, final int y, final int touchId) {
return this.screens.pointerButton(x / this.scale, y / this.scale, MouseButton.LEFT, true, Modifiers.NONE,
new InputContext(Device.TOUCH, touchId, 1f));
}
The forms without a context pass InputContext.MOUSE for pointer input and InputContext.KEYBOARD for keys and typed characters, so an engine with only a mouse and a keyboard changes nothing. The device goes into the input's cause, where handlers read it.
Drops from other applications¶
A drag from another application enters the host with dragEntered(x, y, transfer, allowed, modifiers), which carries what the drag holds in the formats the engine can name, and the effects the other application allows. An engine that can read the values only at the drop passes a transfer whose values are supplied, and they read as empty until then. Each return value is the effect a component would take, which the adapter shows and reports to the other application, or empty when nothing would take it.
dragMoved(x, y, modifiers)for each move.dragExited()when the drag leaves or is given up.dropped(x, y, transfer, modifiers)at the drop, with the values readable. It returns the effect the target took, or empty.
A drop an engine reports without a drag before it, such as files dropped on a game window, goes to dropped alone, with Transfer.files(paths); the component under the pointer hears DRAG_OVER and DROP together. The defaults do nothing, so an input sink that takes no drags needs no code.
/** Files dropped on the window from the desktop, which the engine reports without a drag before them. */
public boolean onFilesDropped(final int x, final int y, final List<Path> files) {
return this.screens.dropped(x / this.scale, y / this.scale, Transfer.files(files), Modifiers.NONE).isPresent();
}
Drags out of the host to other applications are not part of the SPI.
Opening screens¶
Code opens screens with Strata.screens(), which returns the controller the adapter hands to StrataCore.setScreenController when it starts. Hand it over before any project code can run, such as from the engine's plugin or mod entry point, and keep the controller cheap to create, since it may be asked for before the engine has a window.
- An engine without screens of its own can hand over a
ScreenStack, which is aScreenController:open(screen, theme)pushes a screen in a theme of its own. - An engine with screens of its own implements
ScreenController.open(screen, theme): make an engine screen that holds a host for the Strata screen, show it, and return aScreenHandlefor it. Title the engine screen with the screen'stitle(). The defaultopen(screen)passesThemeRef.platform(). - A screen's handle opens screens above it through the host's opener. Set one with
ScreenHost.setOpener(controller)that puts the new screen above the host's screen and shows the host's screen again, unchanged, when the new one closes. A host with no opener opens through the platform's controller. A stack sets its own. - Name the platform's default theme in
ThemeSource.defaultTheme(). It must resolve against the source and must not bestrata:platform, and changes show once the source'sversion()changes. ScreenHost.handle()returns the open screen's handle, for a controller that has to return one. A controller that wraps it for its own screen forwards each call:requestClose()to the host's handle (host.handle().map(ScreenHandle::requestClose).orElse(false)),isOpen()to whether the host has a handle while this opening is the current one, andsetTitle(title), returning the wrapper.- A controller that opens screens from other threads hands the opening to the UI thread and returns a handle at once, whose calls wait for the screen.
ScreenStackdoes this itself. - The runnable given to
createScreenHost(platform, theme, closeRequested)runs when the screen's handle closes it, throughclose()or through arequestClose()the screen agreed to. It does not run forScreenHost.requestClose(), which the adapter calls itself and then closes its own screen when it returns true. - A
ScreenStackhasrequestClose()too: it asks the top screen, removes it if it agrees, then the next, and stops at the first screen that refuses.title()is the top screen's.
Contract tests¶
The SPI's test fixtures hold TextMetricsContract, PlatformContract and CanvasContract. Extend each with your implementation:
import org.inspirenxe.strata.api.text.TextMetrics;
import org.inspirenxe.strata.examples.adapter.MonospaceTextMetrics;
import org.inspirenxe.strata.examples.adapter.RecordingCanvas;
import org.inspirenxe.strata.examples.adapter.ToyPlatform;
import org.inspirenxe.strata.spi.Canvas;
import org.inspirenxe.strata.spi.Platform;
import org.inspirenxe.strata.spi.testing.CanvasContract;
import org.inspirenxe.strata.spi.testing.PlatformContract;
import org.inspirenxe.strata.spi.testing.TextMetricsContract;
import org.junit.jupiter.api.Nested;
/**
* Holds the example adapter to the same contracts as Strata's own adapters.
*/
class AdapterContractTests {
@Nested
class Metrics extends TextMetricsContract {
@Override
protected TextMetrics metrics() {
return new MonospaceTextMetrics();
}
}
@Nested
class Platforms extends PlatformContract {
private ToyPlatform last;
@Override
protected Platform platform() {
this.last = new ToyPlatform();
return this.last;
}
@Override
protected void passTime(final long millis) throws InterruptedException {
// The engine's main loop runs the scheduler each frame; a test runs it after waiting
Thread.sleep(millis);
this.last.scheduler().runDue();
}
}
@Nested
class Canvases extends CanvasContract {
@Override
protected Surface surface(final int width, final int height) {
// The recording canvas has no pixels to read back, so only the checks that need none run
final RecordingCanvas canvas = new RecordingCanvas();
canvas.begin();
return new Surface() {
@Override
public Canvas canvas() {
return canvas;
}
};
}
}
}
CanvasContract needs a Surface per check. A canvas that cannot read back its pixels, like this one, runs the checks that need no pixels: every call is accepted with ordinary and edge-case arguments, and deep clip and transform stacks unwind. A canvas that draws to readable pixels, such as an offscreen buffer, returns readable() == true and implements argb(x, y). Then the pixel checks run too: fills cover exactly their rectangle, alpha 0 draws nothing, later draws cover earlier ones, clips limit and restore, nested clips intersect, translations shift and restore, gradients run top to bottom, missing sprites stay visible and inside their rectangle, nine-slices stay inside small rectangles, and text stays within its measured width. Override metrics() to enable the text check. Surfaces use one UI unit per pixel on a transparent background.
CanvasContract has two more hooks, drawsShapes() and drawsPixels(), which return false by default. Override them to return true for a canvas that implements the shape calls and draws pixel buffers itself. The shape checks then run: every call is accepted with ordinary and edge-case arguments, and on a canvas that can read its pixels, lines, outlines, ellipses, arcs, shapes, gradients at an angle, shadows and round clips land where the contract says. Every canvas is checked against all of the cursor kinds: showsCursor answers for each and true for the arrow, and requestCursor accepts each.
PlatformContract has hasClipboard() and clockRunsOnItsOwn() to override for a headless clipboard or a hand-driven clock, and translator() to override to run the translator checks. It also checks that the platform's file dialogs are present or absent without throwing, that transfers put on the clipboard never throw, and that a transfer's text reads back through get(). The example adapter's shapes canvas runs the contract with the shape checks on:
@Nested
class Shapes extends CanvasContract {
@Override
protected Surface surface(final int width, final int height) {
final RecordingShapesCanvas canvas = new RecordingShapesCanvas();
canvas.begin();
return new Surface() {
@Override
public Canvas canvas() {
return canvas;
}
};
}
@Override
protected boolean drawsShapes() {
return true;
}
@Override
protected boolean drawsPixels() {
return true;
}
}
Naming¶
Strata's adapters name classes after the engine and the SPI role they fill (SwingCanvas, SwingPlatform), and the engine-native component a developer adds to their engine is Strata plus the engine's base class (StrataPanel, a Swing JPanel). Following that keeps adapters easy to recognize.