它展示了什么
Pulse — the dashboard shape: one screen, read rather than operated.
gradlew lwjgl3:run --args="classname dev.cf.app.dashboard.DashboardApp"
What makes a dashboard different from a listing
A listing is scanned for one row. A dashboard is read in a fixed order, and the order is the design: headline, then supporting figures, then the shape over time, then the breakdown. A reader who has to hunt for the number the screen exists to show is looking at a chart gallery, not a dashboard.
Three decisions carry most of the weight here.
1 · One period, one snapshot, one rebuild
The period chips are the only control. Changing one takes a fresh Metrics and rebuilds
the body from it. Patching the pieces individually is faster and is how a dashboard ends up with
a KPI from March next to a chart from April — the failure nobody notices in review and everybody
notices in a meeting.
2 · Semantic colour is separate from the accent
Up and down are read at a glance and must not be the brand colour, or "on brand" and "doing
well" become the same signal. They come from theme tokens all the same
(Styles.primaryColor, secondary()), so the whole screen still follows the skin
and dark mode.
3 · A number states its own units and its own comparison
"12,480" is not information. "12,480, up 12.4% on last week" is. Every KPI here carries a caption saying what it counts, and the headline carries its delta.
在你的机器上运行
每个示例都是普通的 Window,启动器只需要它的名字。
gradlew lwjgl3:run --args="classname dev.cf.app.dashboard.DashboardApp"Java 是提前编译的,浏览器无法自行重建你的修改 —— 这里的代码在发布前已由 TeaVM 转成 JavaScript。把文件复制到项目里,它在四个平台上原样运行;上面那条命令就是全部准备工作。
它用到了什么
本示例触及的框架类型。带链接的有参考页。
源码
完整无删减。这就是那个文件,不是片段。
package dev.cf.app.dashboard;
import dev.cf.Described;
import dev.cf.Tones;
import dev.cf.app.AppBar;
import dev.cf.app.AppTheme;
import dev.crossfly.ui.*;
import dev.crossfly.ui.chart.*;
import dev.crossfly.ui.color.*;
import dev.crossfly.ui.color.ColorUtils;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import static dev.crossfly.ui.Widgets.*;
/**
* <b>Pulse</b> — the dashboard shape: one screen, read rather than operated.
*
* <pre>
* gradlew lwjgl3:run --args="classname dev.cf.app.dashboard.DashboardApp"
* </pre>
*
* <h2>What makes a dashboard different from a listing</h2>
*
* <p>A listing is scanned for one row. A dashboard is read in a fixed order, and the order is the
* design: <b>headline, then supporting figures, then the shape over time, then the breakdown</b>.
* A reader who has to hunt for the number the screen exists to show is looking at a chart gallery,
* not a dashboard.</p>
*
* <p>Three decisions carry most of the weight here.</p>
*
* <h3>1 · One period, one snapshot, one rebuild</h3>
*
* <p>The period chips are the only control. Changing one takes a fresh {@link Metrics} and rebuilds
* the body from it. Patching the pieces individually is faster and is how a dashboard ends up with
* a KPI from March next to a chart from April — the failure nobody notices in review and everybody
* notices in a meeting.</p>
*
* <h3>2 · Semantic colour is separate from the accent</h3>
*
* <p>Up and down are read at a glance and must not be the brand colour, or "on brand" and "doing
* well" become the same signal. They come from theme tokens all the same
* ({@code Styles.primaryColor}, {@code secondary()}), so the whole screen still follows the skin
* and dark mode.</p>
*
* <h3>3 · A number states its own units and its own comparison</h3>
*
* <p>"12,480" is not information. "12,480, up 12.4% on last week" is. Every KPI here carries a
* caption saying what it counts, and the headline carries its delta.</p>
*/
public class DashboardApp extends Window implements Described
{
private Metrics.Period period = Metrics.Period.WEEK;
private Metrics data;
private Component body;
private Chip[] chips;
@Override
public String title()
{
return "Pulse — a dashboard";
}
@Override
public String note()
{
return "One screen, read rather than operated: a headline number, a KPI grid and charts,"
+ " all re-fed by the single control that changes the period.";
}
/** Hands the palette back to whatever was in force before this app opened. */
@Override
public void onRemove()
{
super.onRemove();
AppTheme.leave();
}
@Override
public void onAdd(boolean firstTime) throws Exception
{
if (!firstTime)
return;
// The brand FIRST: fullscreenColor below reads a token, and reading it before the palette
// is declared bakes in the skin's neutral grey for the life of the window.
AppTheme.enter();
// The PAGE, not the surface. Cards, buttons and fields are all drawn on the surface, so a
// page painted the same colour makes every one of their edges disappear. Read through
// onRestyle so a light/dark flip re-derives it instead of keeping the old mode's value.
fullscreenColor = AppTheme.pageColor();
onRestyle(() -> fullscreenColor = AppTheme.pageColor());
setLayout(new VerticalLayout(Position.START, Position.START));
add(0f, new AppBar("Pulse", isRoot() ? null : this::hide));
add(0f, periodRow());
body = new Component();
body.setLayout(new VerticalLayout(Position.START, Position.START));
body.setScrollable(ScrollDirection.VERTICAL);
body.getStyle()
.ingap(Gap.ofFont(1f, .5f, 1f, 1f));
body.testTag("dash.body");
add(LAY_FILL, body);
rebuild();
}
/** The one control. Chips rather than a dropdown: three options a thumb can reach. */
private Component periodRow() throws Exception
{
Component row = new Component();
row.setLayout(new HorizontalLayout(Position.START, Position.CENTER));
row.getStyle()
.ingap(Gap.ofFont(1f, .5f, 1f, .5f));
Metrics.Period[] all = Metrics.Period.values();
chips = new Chip[all.length];
for (int i = 0; i < all.length; i++)
{
final Metrics.Period p = all[i];
Chip c = new Chip(p.label);
c.testTag("dash.period." + p.name()
.toLowerCase());
c.setSelected(p == period);
c.onAction(e ->
{
period = p;
for (Chip other : chips)
other.setSelected(false);
c.setSelected(true);
rebuild();
Screen.repaint();
});
c.getStyle()
.outgap(Gap.ofFont(0, 0, .4f, 0));
chips[i] = c;
row.add(LAY_AUTO, c);
}
return row;
}
/** Takes a snapshot and builds the whole body from it. Never partially. */
private void rebuild()
{
try
{
data = Metrics.of(period);
body.removeAll();
// removeAll() drops the layout too — put it back before adding anything.
body.setLayout(new VerticalLayout(Position.START, Position.START));
body.add(LAY_AUTO, outgap(hero(), 0, 0, 0, 1f));
body.add(LAY_AUTO, outgap(kpis(), 0, 0, 0, 1.2f));
body.add(LAY_AUTO, outgap(sectionTitle("Revenue over time"), 0, 0, 0, .5f));
body.add(LAY_AUTO, outgap(trend(), 0, 0, 0, 1.2f));
body.add(LAY_AUTO, outgap(sectionTitle("Where it came from"), 0, 0, 0, .5f));
body.add(LAY_AUTO, channels());
body.setValidLayout(false);
}
catch (Exception e)
{
dev.crossfly.util.Debug.handle(e);
}
}
/**
* The hero: the one number the screen exists to show, its delta, and nothing else.
*
* <p>Resisting the fourth item here is most of the work. A hero with four figures in it has no
* hero, and the reader has to decide which one matters — which is the decision the dashboard was
* supposed to have made for them.</p>
*/
private Component hero() throws Exception
{
Card c = new Card();
c.setLayout(new VerticalLayout(Position.START, Position.START));
c.testTag("dash.hero");
c.add(LAY_AUTO, label("REVENUE · " + period.label.toUpperCase(), 1f, true,
Styles.captionColor, Position.START));
Label big = label(data.revenue(), 3f, true, Styles.textColor, Position.START);
big.testTag("dash.revenue");
c.add(LAY_AUTO, outgap(big, 0, .2f, 0, .3f));
Component deltaRow = new Component();
deltaRow.setLayout(new HorizontalLayout(Position.START, Position.CENTER));
PalColor tone = data.deltaIsGood() ? Styles.primaryColor : secondary();
deltaRow.add(LAY_AUTO, matglyph(data.deltaIsGood() ? 0xE5D8 : 0xE5DB, tone, 1.2f));
Label d = label(data.delta() + " on the previous period", 1f, true, tone, Position.START);
d.testTag("dash.delta");
deltaRow.add(LAY_AUTO, outgap(d, .3f, 0, 0, 0));
c.add(LAY_AUTO, deltaRow);
return c;
}
/**
* The supporting figures.
*
* <p>A {@link CardGrid} rather than a fixed row: on a phone these stack, on a tablet they sit
* side by side, and neither case needed a width check or an orientation listener. Reflow is the
* framework's job.</p>
*/
private Component kpis() throws Exception
{
CardGrid g = new CardGrid().columns(2, 4)
.cardWidth(11f)
.gap(.6f);
g.testTag("dash.kpis");
g.card(kpi("Orders", "" + data.orders, "closed in the period", 0xE8CC));
g.card(kpi("Customers", "" + data.customers, "who bought at least once", 0xE7EF));
g.card(kpi("Average order", Metrics.money(data.orders == 0 ? 0
: data.revenueCents / data.orders), "revenue ÷ orders", 0xE263));
g.card(gauge());
return g;
}
private Component kpi(String title, String value, String caption, int glyph) throws Exception
{
Card c = new Card();
c.setLayout(new VerticalLayout(Position.START, Position.START));
c.testTag("dash.kpi." + title.toLowerCase()
.replace(' ', '.'));
c.add(LAY_AUTO, matglyph(glyph, Styles.primaryColor, 1.4f));
c.add(LAY_AUTO, outgap(label(value, 2f, true, Styles.textColor, Position.START), 0, .3f, 0,
.1f));
c.add(LAY_AUTO, label(title, 1f, true, Styles.textColor, Position.START));
// The caption is what turns a number into a fact. It is the first thing cut for space and the
// first thing missed in a meeting.
c.add(LAY_AUTO, label(caption, 1f, false, Styles.captionColor, Position.START));
return c;
}
/** The one KPI that is a ratio, so it gets the shape a ratio deserves. */
private Component gauge() throws Exception
{
Card c = new Card();
c.setLayout(new VerticalLayout(Position.CENTER, Position.CENTER));
c.testTag("dash.kpi.conversion");
RadialProgress r = new RadialProgress().progress(data.conversion)
.text(data.conversionText())
.caption("converted");
c.add(LAY_AUTO, r);
c.add(LAY_AUTO, outgap(label("Conversion", 1f, true, Styles.textColor, Position.CENTER), 0,
.4f, 0, 0));
return c;
}
/**
* Revenue and orders on one chart.
*
* <p>Two series that share a period belong on one chart; two charts side by side make the reader
* do the alignment themselves and they will do it wrong. The categories come from the same
* snapshot as the values, which is what keeps the axis honest when the period changes.</p>
*/
private Component trend() throws Exception
{
Card c = new Card();
c.setLayout(new VerticalLayout(Position.START, Position.START));
ComboChart chart = new ComboChart();
chart.categories(data.labels);
// The colour is passed explicitly. autoThemeColors below themes the axes, the grid and the
// plot background, but NOT the series: a series left at colour 0 falls back to
// ChartView.DEFAULT_COLORS, whose first entry is a dark grey — so the largest thing on the
// screen ends up being the one thing that ignores the brand.
chart.addSeries(new ChartView.Series("Revenue", data.revenueSeries,
Styles.primaryColor.color()).bar());
// The second series is the SAME hue, shaded towards the text colour rather than a different
// colour entirely: the two are measuring the same thing over the same period, so they should
// look related. Shading towards the text and not towards black is what keeps it working in
// dark mode, where "darker" is the wrong direction.
// A ROLE rather than the brand dimmed towards the text colour: two measures on one chart
// have to be told apart without consulting the legend, and the dimmed brand read as grey.
chart.addSeries(new ChartView.Series("Orders", data.ordersSeries, Tones.highlight().color())
.line());
chart.showLegend = true;
chart.autoThemeColors(true);
chart.getStyle()
.autoHeight(Relative.ofFont(14f));
chart.testTag("dash.trend");
c.add(LAY_AUTO, chart);
return c;
}
/** The breakdown. A donut, because the question is "what share", not "how much". */
private Component channels() throws Exception
{
Card c = new Card();
c.setLayout(new VerticalLayout(Position.START, Position.START));
DonutChart d = new DonutChart();
// One hue, four steps, darkest first — a share-of-total chart is asking "how much of it",
// and four unrelated colours make the reader learn a legend to answer that. The ramp is
// derived from the brand, so it follows a change of BRAND and it follows dark mode.
int base = Styles.primaryColor.color(), ground = Styles.backColor.color();
for (int i = 0; i < Metrics.CHANNEL.length; i++)
d.slice(Metrics.CHANNEL[i], data.channelValues[i],
ColorUtils.lerpColor(base, ground, i * .21f));
d.hole(.62f);
d.autoThemeColors(true);
d.getStyle()
.autoHeight(Relative.ofFont(14f));
d.testTag("dash.channels");
c.add(LAY_AUTO, d);
return c;
}
private Component sectionTitle(String t) throws Exception
{
return label(t, 1.2f, true, Styles.textColor, Position.START);
}
}package dev.cf.app.dashboard;
/**
* The numbers behind the dashboard, and the one place that knows what a period means.
*
* <p>A dashboard has exactly one control — the period — and every single thing on the screen has to
* answer to it. The failure mode is not subtle and it is extremely common: one card keeps showing
* last month while the chart beside it has already moved, and from that moment the screen is worse
* than no dashboard, because it is confidently wrong.</p>
*
* <p>The defence is structural rather than careful: <b>one object holds the whole snapshot, and the
* screen rebuilds from it.</b> There is no path by which half the screen can be from one period.
* Nothing here reaches out for its own number.</p>
*
* <p>The figures are generated so the example has no backend to depend on, but they are generated
* <i>deterministically</i> from the period — a dashboard whose numbers change every time you look
* at it is impossible to develop against and impossible to screenshot-test.</p>
*/
public class Metrics
{
/** The periods the header offers, and the only axis this screen has. */
public enum Period
{
/** The last seven days, by day. */
WEEK("This week", 7),
/** The last four weeks, by week. */
MONTH("This month", 4),
/** The last twelve months, by month. */
YEAR("This year", 12);
/** What the chip says. */
public final String label;
/** How many buckets the charts have. */
public final int buckets;
Period(String label, int buckets)
{
this.label = label;
this.buckets = buckets;
}
}
/** The period this snapshot was taken for. */
public final Period period;
/** The headline figure, in cents. */
public final long revenueCents;
/** Change against the previous comparable period, as a percentage. May be negative. */
public final float revenueDelta;
/** Orders closed. */
public final int orders;
/** Distinct customers who bought. */
public final int customers;
/** Share of visits that ended in an order, 0..1 — the gauge. */
public final float conversion;
/** Revenue per bucket, oldest first. */
public final double[] revenueSeries;
/** Orders per bucket, oldest first — the second series on the same chart. */
public final double[] ordersSeries;
/** The bucket labels, matching {@link #revenueSeries}. */
public final String[] labels;
/** Revenue split by channel — the donut. */
public final double[] channelValues;
/** The channel names, matching {@link #channelValues}. */
public static final String[] CHANNEL = { "Field", "Web", "Phone", "Partner" };
private Metrics(Period p)
{
period = p;
labels = labelsFor(p);
revenueSeries = new double[p.buckets];
ordersSeries = new double[p.buckets];
long total = 0;
int ord = 0;
for (int i = 0; i < p.buckets; i++)
{
// Deterministic, and shaped like something rather than flat: a gentle rise with a wobble.
double base = 900 + i * 140 + (i % 3) * 210 - (i % 5) * 90;
double scale = p == Period.WEEK ? 1 : p == Period.MONTH ? 5.5 : 23;
revenueSeries[i] = Math.round(base * scale);
ordersSeries[i] = Math.round(revenueSeries[i] / 148.0);
total += (long) (revenueSeries[i] * 100);
ord += (int) ordersSeries[i];
}
revenueCents = total;
orders = ord;
customers = (int) (ord * 0.72);
conversion = p == Period.WEEK ? .41f : p == Period.MONTH ? .38f : .35f;
revenueDelta = p == Period.WEEK ? 12.4f : p == Period.MONTH ? 6.1f : -3.8f;
channelValues = new double[] { total * .46 / 100, total * .28 / 100, total * .17 / 100,
total * .09 / 100 };
}
/**
* Takes one snapshot for a period.
*
* <p>Everything the screen shows comes from the object this returns, and the screen holds no
* other source. That is the invariant worth keeping when you replace the generator with a real
* query — including when the real query is four queries, which should still be assembled into
* one snapshot here rather than fetched one card at a time.</p>
*
* @param p the period
* @return the snapshot
*/
public static Metrics of(Period p)
{
return new Metrics(p);
}
/** The headline revenue, formatted. */
public String revenue()
{
return money(revenueCents);
}
/** The delta, with its sign, as the header shows it. */
public String delta()
{
String s = "" + Math.round(Math.abs(revenueDelta) * 10) / 10f;
return (revenueDelta >= 0 ? "+" : "−") + s + "%";
}
/** Whether the delta is an improvement — which is not the same as being positive, for a cost. */
public boolean deltaIsGood()
{
return revenueDelta >= 0;
}
/** The conversion gauge's centre text. */
public String conversionText()
{
return Math.round(conversion * 100) + "%";
}
/**
* Formats cents as money.
*
* @param cents the amount
* @return the formatted amount, thousands separated
*/
public static String money(long cents)
{
long units = cents / 100;
StringBuilder sb = new StringBuilder();
String s = "" + units;
for (int i = 0; i < s.length(); i++)
{
if (i > 0 && (s.length() - i) % 3 == 0)
sb.append(',');
sb.append(s.charAt(i));
}
return sb.toString();
}
private static String[] labelsFor(Period p)
{
if (p == Period.WEEK)
return new String[] { "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" };
if (p == Period.MONTH)
return new String[] { "W1", "W2", "W3", "W4" };
return new String[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
"Nov", "Dec" };
}
}package dev.cf.app;
import dev.crossfly.ui.*;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;
import static dev.crossfly.ui.Widgets.*;
/**
* The top bar the four example apps share: back, title, and up to a couple of actions.
*
* <p>There is no top bar in CrossFly, and that is not an omission. A title bar is the single most
* app-specific piece of chrome there is — height, colour, whether it collapses, whether the title
* centres, what sits on the right — and a framework that decided those would be fought by every
* app that used it. What the framework supplies is what a bar is <i>made of</i>: a container, a
* layout, buttons, glyphs, and a style that follows the skin.</p>
*
* <p>So this is an <b>app-level</b> class, shared by the four examples here because they are four
* examples in one repository — in a real project it would live in your own {@code ui} package. It
* is about forty lines, which is roughly what a top bar costs, and it is worth reading as the
* answer to "how do I compose chrome out of primitives".</p>
*
* <pre>
* add(0f, new AppBar("Part", this::hide).action(0xE3C9, this::edit));
* </pre>
*/
public class AppBar extends Component
{
private final String title;
private final Runnable onBack;
private final int[] icons = new int[3];
private final Runnable[] actions = new Runnable[3];
private final String[] tags = new String[3];
private int count;
/**
* @param title the heading
* @param onBack what the back arrow does, or null for a bar with no back arrow
*/
public AppBar(String title, Runnable onBack)
{
this.title = title;
this.onBack = onBack;
}
/**
* Adds a right-hand action. Up to three; past that the answer is a menu, not a fourth glyph.
*
* @param codepoint the Material Icons codepoint
* @param tag the test tag for the button
* @param action what it does
* @return this bar, for chaining
*/
public AppBar action(int codepoint, String tag, Runnable action)
{
if (count < icons.length)
{
icons[count] = codepoint;
tags[count] = tag;
actions[count] = action;
count++;
}
return this;
}
@Override
public void onAdd(boolean firstTime) throws Exception
{
if (!firstTime)
return;
// A strip of surface with a hairline of elevation under it. Both colours are theme tokens, so
// the bar is right in dark mode and under every skin without a branch anywhere — and the
// shadow is expressed as a ratio of the font height, so it survives a high-DPI screen, where
// a shadow measured in pixels all but disappears.
getStyle().backColor(Styles.backColor)
.shadow(0, .06f, .25f, Styles.borderColor)
.ingap(Gap.ofFont(.5f, .4f, .5f, .4f));
setLayout(new GridLayout(Position.START, Position.CENTER));
// A bar with no back arrow gets no COLUMN for one. The obvious shortcut — always build the
// column and fill it with a bare `new Component()` — silently kills the entire row: an empty
// container has no layout, an auto-width column cannot measure it, and the bar comes out
// zero-high with the title inside it. It looks like the title was never added.
boolean hasBack = onBack != null;
int columns = (hasBack ? 1 : 0) + 1 + count;
Object[] cells = new Object[columns * 2];
int k = 0;
if (hasBack)
cells[k++] = 0f; // back: its own width
cells[k++] = 1f; // title: everything left over
for (int i = 0; i < count; i++)
cells[k++] = 0f; // actions: their own width
if (hasBack)
{
Button b = new Button(MaterialIcon.of(0xE5C4, 1.4f)); // arrow_back
b.testTag("bar.back");
b.onAction(c -> onBack.run());
b.getStyle()
.outgap(Gap.ofFont(0, 0, .5f, 0));
cells[k++] = b;
}
Label t = label(title, 1.5f, true, Styles.textColor, Position.START);
t.testTag("bar.title");
cells[k++] = t;
for (int i = 0; i < count; i++)
{
Button b = new Button(MaterialIcon.of(icons[i], 1.4f));
final Runnable a = actions[i];
b.testTag(tags[i]);
b.onAction(c -> a.run());
b.getStyle()
.outgap(Gap.ofFont(.4f, 0, 0, 0));
cells[k++] = b;
}
add(cells);
}
}package dev.cf.app;
import dev.crossfly.ui.color.ColorScheme;
import dev.crossfly.ui.style.Styles;
/**
* The one line that gives an app its colour, and the reason the four examples here have one.
*
* <p><b>An app declares its brand; it does not pick colours.</b> One seed goes in, and CrossFly
* derives the whole scheme from it — primary, surface, on-surface, containers, the tonal fills
* behind an active tab, the ring of a gauge, both themes. Nothing downstream names a colour, which
* is what makes dark mode and the skin picker work without a single branch.</p>
*
* <p>Skip this and the app is not "unstyled", it is <b>grey</b>: the skin's neutral palette is what
* you get when nobody declared anything, and it looks exactly like a bug. That is what happened to
* these four — they were correct from the samples menu, which declares a brand of its own, and
* monochrome when launched straight from {@code classname}, which is how anybody copying them would
* run them.</p>
*
* <h3>Why enter/leave and not just a call at startup</h3>
*
* <p>The brand is global state. A real app declares it once in {@code Main} and never thinks about
* it again — and if that is your case, {@code Styles.setBrand(BRAND)} at startup is the whole story
* and this class is more machinery than you need.</p>
*
* <p>These four are different: they are guests. They open from a menu that has its own brand, and
* from a test suite whose later screenshots are compared against committed images. A guest that
* changes global state and walks away leaves its host repainted in someone else's colour — the
* samples menu comes back blue, the goldens fail three tests later, and neither failure points at
* the app that caused it. So each app calls {@link #enter()} when it opens and {@link #leave()}
* when it closes, and the palette that was in force is handed back.</p>
*/
public final class AppTheme
{
/**
* The seed every colour in the four example apps is derived from.
*
* <p>Deep blue, chosen against orange for one reason: orange is the brand of the production app
* these examples were extracted from, and an example that wears it invites the reader to think
* the two are related. For orange instead, this is the only line to change —
* {@code 0xFFE2622C} is a good one. Nothing else in the four apps names a colour.</p>
*/
public static final int BRAND = 0xFF1565C0;
/**
* The page behind the cards: the surface, pulled a little way towards the brand.
*
* <p><b>Why a page needs its own colour at all.</b> Cards, buttons and fields are all drawn on
* the skin's surface, which in a light theme is white or near it. Put them on a white page and
* every edge disappears — the card stops reading as a card and a white button on white looks
* like a mistake rather than a control. What separates them is not a border, it is the page
* being a shade the foreground is not.</p>
*
* <p><b>Why it is derived and not a hex.</b> {@code 0xFFF2F5F9} would be a perfectly nice light
* blue and would be wrong twice: it stops being light blue the moment somebody changes
* {@link #BRAND} to orange, and it stays light in dark mode, where the page must go darker than
* the cards rather than lighter. Mixing the primary into the surface at 8% follows both — the
* tint always matches the brand, and in a dark theme the same call yields a deep, slightly
* tinted ground.</p>
*
* <p>Read it through {@code onRestyle} rather than once, so a theme flip re-derives it.</p>
*
* @return the ARGB page colour for the palette currently in force
*/
public static int pageColor()
{
return dev.cf.Tones.page();
}
/** What was in force before the outermost {@link #enter()}. */
private static Integer previousBrand;
private static ColorScheme previousScheme;
/** How many example apps are open. Restores only when the last one closes. */
private static int depth;
private AppTheme()
{
}
/**
* Declares {@link #BRAND}, remembering whatever was in force.
*
* <p>Call it first thing in {@code onAdd}. It triggers a restyle, so every window already open
* re-resolves its colours — including this one, which is why it can run before the screen is
* built.</p>
*/
public static void enter()
{
if (depth++ == 0)
{
previousBrand = Styles.hasBrand() ? Integer.valueOf(Styles.brand()) : null;
ColorScheme live = Styles.colorScheme();
if (live != null)
{
// A SNAPSHOT, never the reference. Styles keeps ONE ColorScheme instance for the life
// of the app and mutates its roles in place on every seed change — deliberately, so
// that a PalColor captured into a style at build time keeps resolving and follows the
// theme forever. The consequence for anybody saving and restoring: the object you kept
// IS the object about to be overwritten, so handing it back restores nothing at all.
// This cost four broken golden images in a suite that had been green all morning.
previousScheme = ColorScheme.fromSeed(BRAND, live.isDark);
previousScheme.copyFrom(live);
}
}
Styles.setBrand(BRAND);
}
/**
* Hands the palette back to the host.
*
* <p>Call it from {@code onRemove}, which CrossFly runs on {@code hide()}. Two cases: the host
* had declared a brand, so re-declaring it regenerates everything; or nothing was declared, so
* the declaration is dropped <b>and</b> the scheme that was actually in force is restored —
* clearing the brand alone would leave this app's blue behind, still painted, just no longer
* owned by anybody.</p>
*/
public static void leave()
{
if (depth > 0 && --depth == 0)
{
if (previousBrand != null)
Styles.setBrand(previousBrand.intValue());
else
{
Styles.clearBrand();
if (previousScheme != null)
Styles.setColorScheme(previousScheme);
}
previousBrand = null;
previousScheme = null;
}
}
}本页的机器可读版本,让模型直接读取示例集,而不是猜测 API。