Themes and styling¶
Global theme¶
from apkpy_lib import Theme
app_theme = Theme(
mode="dark",
primary="#8B5CF6",
secondary="#22D3EE",
background="#09090B",
surface="#18181B",
text="#FAFAFA",
text_secondary="#A1A1AA",
border="#3F3F46",
error="#FCA5A5",
success="#4ADE80",
radius=16,
spacing=14,
font_family="sans-serif",
)
Pass the theme to run():
The theme styles screens, text, buttons, inputs, containers, cards, lists, navigation, player surfaces and Android system bars.
Design tokens¶
Reference normalized theme values from CSS:
body {
background-color: var(--background);
color: var(--text);
}
panel {
background-color: var(--surface);
border-color: var(--border);
border-radius: var(--radius);
padding: var(--spacing);
}
danger_action {
background-color: var(--error);
}
Available tokens:
primary, secondary, background, surface, text, text_secondary, on_primary, error, success, border, radius, spacing, motion, nav_indicator, font-family, surface_low, surface_high, border_subtle, the seven steps text-xs to text-3xl and the three leading-* multipliers.
A dash reads as an underscore, so var(--text-secondary) and
var(--text_secondary) are the same token.
A ramp instead of a number¶
Seven steps, so a heading and a caption are two names rather than two guesses.
They are multiples of Theme(font_size=14), and they resolve to Material's
own sp values rather than to a geometric series -- one ratio lands on 29.3
where the platform says 32, and the two renderers round it differently.
| Token | Default | Typical use |
|---|---|---|
--text-xs |
11px | overline, timestamps, a tab label |
--text-sm |
12px | captions, helper text under a field |
--text-base |
14px | dense list rows |
--text-lg |
16px | body text -- what label() uses when asked nothing |
--text-xl |
20px | a section heading |
--text-2xl |
24px | a screen title |
--text-3xl |
32px | a number to be read across the room |
title { font-size: var(--text-2xl); line-height: var(--leading-tight); }
body { font-size: var(--text-lg); line-height: var(--leading-normal); }
caption { font-size: var(--text-sm); color: var(--text-secondary); }
--leading-tight, --leading-normal and --leading-loose are 1.2, 1.45 and
1.7. line-height reads a bare number as a multiple of the font size on both
sides, so the multipliers work anywhere a length does.
Raising the base moves all seven together:
A heading that was 20sp becomes 26sp, body text 21sp, a display number 41sp -- proportional, in one edit rather than seventeen.
Planes between the background and the surface¶
A card has to sit on a surface. A well has to sit under one. A divider should separate without shouting.
sheet { background-color: var(--surface-high); }
well { background-color: var(--surface-low); }
group { divider-color: var(--border-subtle); divider-width: 1px; }
When the app declares its own surface, background or border, the three
are derived from those colours rather than from Material's palette -- so a
warm theme does not grow a cold grey slab in the middle of it.
| Token | How it is derived |
|---|---|
--surface-high |
the surface, 6% toward the text |
--surface-low |
55% of the way from the surface to the background |
--border-subtle |
35% from the surface toward the border |
Like every other theme colour, all three are written into layouts and
drawables as resource references, so values-night/ answers them and
appearance.set() moves them while the app runs.
A subtle divider is not always the better one
If a theme's border already sits close to its surface, it is already
playing the quiet role and there is no room underneath it. Render it and
look before you swap a working divider for --border-subtle.
A token in the wrong kind of slot¶
--text is a colour and --text-lg is a size, three characters apart. Using
one where the other belongs raises U2031 instead of failing quietly --
a colour in a size slot would otherwise turn #211F26 into 21px and simply
look wrong. A composite value such as 0 3px 8px var(--border) is left alone.
Tokens work without a theme¶
An app that never calls run(theme=...) still has a theme: the
generated apkpy_theme.xml is written from ApkPy's default
Material palette. var(--primary) reads that palette and
resolves to #6750A4, which is the same colour the built app
uses for a filled button.
from apkpy_lib import Screen, button, label, run
home = Screen(id="home")
label("Welcome", id="title", screen=home)
button("Continue", id="go", variant="filled", screen=home)
run(start_screen=home) # no theme named
style = """
title { color: var(--primary); }
"""
Declaring a theme changes what the token resolves to, never whether it resolves.
Switching appearance while the app runs¶
from apkpy_lib import appearance
appearance.set("light") # "dark", "light" or "system"
appearance.get() # what is in force
The choice is remembered, so the app opens the way it was left.
A remembered choice outranks Theme(mode=...)
Theme(mode="dark") is where the app starts, not a setting it re-applies
on every launch -- otherwise choosing light would last until the next
restart and no further. Once anything has called appearance.set(...),
that choice wins.
This surprises people while they are still writing the app: you change
mode="dark", run it, and nothing looks different. In the Previewer the
choice lives in apkpy_storage.json, next to your main script rather
than in the folder you ran the command from. Delete that key (or the file)
and the app goes back to opening the way it declares itself. On the phone,
the same reset is Settings > Apps > your app > Storage > Clear data.
What makes this possible is that a colour which came from a token is not written into the layout at all -- a reference to it is:
<!-- res/values/apkpy_theme.xml -->
<color name="apkpy_text">#1D1B20</color>
<!-- res/values-night/apkpy_theme.xml -->
<color name="apkpy_text">#F5F4EF</color>
Android answers that reference from one table or the other depending on the mode in force. Switching costs nothing at run time: the resource system does the work while the layout inflates.
A colour you wrote by hand is left exactly as you wrote it.
#C96442 in a stylesheet was a decision, not a default, and a decision that
changes on its own is a bug. Only tokens move.
Where the second palette comes from¶
You declare one appearance; ApkPy builds the other from the same Theme. The
accent carries over and the surfaces flip:
| Token | In the counterpart |
|---|---|
primary, secondary, on_primary, error, success |
kept |
background, surface, text, text_secondary, border |
from the opposite palette |
A background you chose at #1B1B19 was chosen because the mode was dark.
Carrying it into light mode would give a light mode that is still dark -- a
switch that appears to do nothing.
An app that never calls appearance.set(...) is pinned to the mode it
declared, on a phone set either way, exactly as before.
A name with no token behind it¶
var(--muted) is not a token, so it is reported as
U2028 the moment the stylesheet is read -- by the
Previewer and by apkpy build alike, since both resolve
tokens through the same module:
APKPY U2028 - This stylesheet asks for a theme token that does not exist
Received:
title { color: var(--muted); }
How to fix:
1. Did you mean var(--text_secondary)?
2. The tokens are: background, border, error, font-family, motion,
nav_indicator, on_primary, primary, radius, secondary, spacing,
success, surface, text, text_secondary.
A colour of your own goes in as itself -- #C96442 -- rather
than through var().
Cascade¶
Styles resolve in this order:
The ID rule changes the background of save_button without losing the shared button radius.
The whole vocabulary¶
An ApkPy stylesheet is not a browser stylesheet. It reads 87 properties,
and a name outside this table is reported as
U2029 and ignored:
| Area | Properties |
|---|---|
| Colour | color, background-color, border-color, pressed-color, focus-color, focus-border-color, accent-color, active-color, hint-color, icon-color, placeholder-color, secondary-color, subtitle-color, title-color, trailing-color, meta-color, badge-color, badge-background-color, item-background-color, item-border-color, divider-color, indicator-color, tint |
| Type | font-size, font-weight, font-family, font-style, text-align, text-transform, letter-spacing, line-height, title-lines, subtitle-lines, subtitle-size, trailing-size, rows, max-rows |
| Shape | border-width, border-radius, box-shadow |
| Space | padding, padding-top, padding-right, padding-bottom, padding-left, margin, margin-top, margin-right, margin-bottom, margin-left, gap, divider-width, divider-inset |
| Size | width, height, min-height, max-width, aspect-ratio, icon-size, item-size |
| Layout | display, flex-direction, flex-grow, flex-shrink, flex-basis, flex-wrap, justify-content, align-items, align-self, grid-template-columns, grid-column, grid-row, position, top, right, bottom, left, z-index |
| Effects | opacity, object-fit, filter, scale |
| Behaviour | transition, press, code-copy, animation-name, animation-duration |
It is a warning, not a failure -- the build carries on, the way it does for an icon name the catalogue does not have. What it buys you is the difference between "that value did not work" and "that name does not exist", which is the difference between adjusting and guessing.
card { elevation: 4px; } /* U2029: did you mean box-shadow? */
card { background: #fff; } /* U2029: did you mean background-color? */
Depth¶
box-shadow is written to Android as android:elevation, and the first pixel
value in the declaration is the depth:
Android draws that shadow outside the view's own box, and a ViewGroup
clips its children to its padding. ApkPy stops the clipping on the parents of
anything that asks for a shadow, which is the other half of why box-shadow
used to look like it did nothing. Only a screen that asks for one is touched.
The Previewer approximates -- Tk has no blurred shadow, so it offsets two rounded layers. What matches is the presence and the ordering, not the blur.
Padding, all four ways¶
padding reads the way CSS reads: one value, two, three or four, and the long
names on top of them.
card { padding: 16px; } /* every side */
card { padding: 8px 16px; } /* vertical, horizontal */
card { padding: 4px 8px 12px; } /* top, horizontal, bottom */
card { padding: 0 20px 24px 20px; } /* top, right, bottom, left */
card { padding: 10px; padding-bottom: 30px; }
The ones worth a paragraph¶
Button labels¶
Material shouts button labels, so ApkPy uppercases them -- "Opus 5" reaches
the screen as OPUS 5. text-transform: none opts out, which is what a chip,
a pill or a chat composer wants:
uppercase and none are the two values offered, because they are the two
Android can express as a display attribute (android:textAllCaps).
capitalize and lowercase would mean rewriting the label at build time and
would then not apply to text you set while the app runs -- so ApkPy reports
them as U2021 instead of half-doing them. Write the label with the casing you
want and use text-transform: none.
A settings row instead of a fat pill¶
A button centres its label, and three of them stacked read as three pills, not
as a list. text-align: left moves the label to the leading edge and brings
the icon with it -- on Android that is android:gravity="start" plus
app:iconGravity="start", which is the difference between Material's
icon-and-label-in-the-middle and a settings row:
pref_model, pref_theme, pref_bell {
background-color: var(--surface);
text-align: left;
text-transform: none;
letter-spacing: 0px;
padding: 0px 18px;
min-height: 52px;
border-radius: 16px;
width: 100%;
}
left, center and right are the three values, and they are written to
Android as start / center / end so a right-to-left locale mirrors the row
without the app asking. justify is reported as U2022 rather than half-done:
it needs android:justificationMode, which arrived at API 26 while ApkPy
targets 24, so it would be an effect only newer phones ever showed.
Alignment needs room to move something. A label in a display: flex row is
sized to its own content -- the same as a shrink-to-fit box in CSS -- so give
it width: 100% if you want the alignment to bite.
text-align: center on the app bar centres its title, the way a settings
screen or a chat header usually wants it:
Android centres it in the whole toolbar rather than in what the leading icon and the actions leave over, and the Previewer copies that -- otherwise the title drifted left as soon as an action appeared.
Tracking and leading¶
letter-spacing opens or tightens the gaps between letters, and line-height
sets how tall one line of text stands. They are what makes a small-caps section
header read as a header and a paragraph read as prose:
kicker { font-size: 11px; font-weight: bold; letter-spacing: 1.2px; }
name { font-size: 24px; letter-spacing: -0.4px; }
blurb { font-size: 14px; line-height: 1.6; }
letter-spacing takes px or em (0.08em and 1.28px mean the same thing
at 16px) and negative values, which is what a large heading usually wants.
line-height follows CSS: a bare number is a multiple of the font size, a
length is the height of the line itself. normal on either one leaves the
component's own spacing alone.
Write letter-spacing: 0px on a button when you mean it. Material tracks
button labels at about 0.089em on its own, so a row that says nothing keeps
that spacing on the phone.
What the Previewer does not do. Tk has no tracking and no line spacing on a
label, so the Previewer shows the right words at the right size without the
gaps between them. What it does honour is the measuring: letter-spacing
changes where a button's label wraps and how wide the button asks to be, and
line-height adds the leading above and below the text, so a single line takes
the same height it takes on the phone. A paragraph that wraps comes out shorter
in the Previewer than on the device, by the leading of each line after the
first. Check that one on a phone.
Your own typeface¶
Everything above is spacing. The font is the part that makes an app stop
looking like every other app built with the same tool. Point font() at the
files and name the family in CSS:
from apkpy_lib import font
font("Tiempos",
regular="fonts/Tiempos-Regular.ttf",
bold="fonts/Tiempos-Bold.ttf",
italic="fonts/Tiempos-Italic.ttf")
theme = Theme(font_family="Tiempos")
app_bar { font-family: "Tiempos"; } /* the title, in the serif */
account_name { font-family: "Tiempos"; font-size: 26px; }
body { font-family: sans-serif; } /* and the reading, in the sans */
The Android build copies the files into res/font, writes the <font-family>
that maps weights onto them, and reaches them through app:fontFamily -- the
AppCompat attribute, because the framework one only learned to take a font
resource at API 26 and ApkPy targets 24. The Previewer loads the same files
into the session without installing anything on your machine.
Serif for the things that carry the name and sans for the things people read is most of what makes a screen look designed, and it costs two declarations.
Four slots, and no more. regular, bold, italic and bold_italic are
what both sides can address: Tk has a family plus the two modifiers, and
Android expresses the same four as fontWeight/fontStyle pairs. A medium
or a semibold would render on the phone and not on the desktop, so font()
refuses them (U2024) rather than half-doing it. If you need a third weight,
register it as its own family and name it where you want it.
A slot you leave out is synthesised -- faux bold, a sheared italic -- by
Android and by Tk alike, so the two agree about what they are faking. A file
that is missing or is not a .ttf/.otf is reported at build time (U2025,
U2026) and that slot is dropped; the family still ships with whatever
survived, and a family with nothing left is never referenced by a layout.
What the Previewer does not do. Loading a font file into Tk is platform-specific. Windows and Linux work. macOS declines, falls back to the nearest system family and says so once in the console -- driving CoreText through ctypes without a Mac to test on is how you put a crash in someone else's Previewer. The APK is unaffected either way.
Text drawn by Android rather than by your layout does not pick the family up
yet: the labels in a bottom_nav and the rows of a virtual_collection stay
on the system font. The app bar title does carry it, through a generated text
appearance.
Borderless surfaces¶
border-width: 0 means no border, focused or not, and `background-color:
00000000` is a transparent surface -- an input that sits directly on the¶
container behind it, with no box of its own. Both work in the Previewer and on the phone.
composer {
background-color: var(--surface);
border-color: var(--border);
border-width: 1px;
border-radius: 28px;
padding: 16px;
}
field {
background-color: #00000000;
border-width: 0px;
placeholder-color: var(--text-secondary);
}
Colours are written the way Android reads them: #RRGGBB or #AARRGGBB, and
the #RGB / #ARGB shorthands expand to those. Anything else is reported as
U2020 at build time rather than throwing while the screen is created.
A composer that grows with what you write¶
On a type="textarea", rows is the height it starts at and max-rows is
where it stops growing. Between the two it follows the text:
One line is the right start for a reply box -- two fixed lines are half an empty composer waiting -- and six is where a draft stops eating the thread. Past the ceiling the field scrolls instead of growing.
Without max-rows the ceiling stays what it always was: twice rows, and
never less than eight.
Copying a code block¶
code-copy: button puts a tappable Copy under every fenced block a
markdown() component or a collection's markdown slot renders. It copies
that block and nothing else -- not the paragraph above it, not the whole
message.
On Android 13 and later the system shows its own confirmation, so the app stays quiet; below that it says "Copied" itself.
A row of controls¶
display: flex; flex-direction: row lays children across, and an empty
flex-grow: 1 container is the spacer that pushes the rest to the far edge:
controls {
display: flex; flex-direction: row; align-items: center;
gap: 8px; width: 100%;
}
spacer { flex-grow: 1; }
chip { flex-grow: 0; flex-shrink: 0; }
Each child asks for the width of its own content, the same as Android's
wrap_content. When the row is wider than the screen, flex-shrink decides
what gives: the default of 1 squeezes the children, and Android answers a
squeezed button by wrapping its label mid-word. flex-shrink: 0 keeps a pill
at its natural width instead — and then a row that still does not fit is
clipped rather than wrapped. Neither is a good look, so count the row: on a
400dp phone, four or five controls is the ceiling.
A hidden child takes no space in either runtime, so swapping one control for
another with show() / hide() re-flows the row rather than leaving a gap.
Rows that hold more than a line¶
A list or collection row shows one line of title and one of subtitle, and cuts the rest off. That is right for a list and wrong for a chat, where the message is the content:
thread {
height: 430px;
subtitle-lines: 4;
item-background-color: var(--surface);
title-color: var(--text);
subtitle-color: var(--text-secondary);
}
title-lines does the same for the title. The row's own height still does the
cutting off, so raise item_height= alongside it.
Responsive style rules¶
Use media rules when only style values change across widths:
content {
padding: 18px;
}
@media (min-width: 600px) {
content {
padding: 32px;
max-width: 900px;
}
}
Use responsive() when the component arrangement itself must change.
Animations¶
@keyframes appear {
from { opacity: 0; scale: 0.96; }
to { opacity: 1; scale: 1; }
}
hero_card {
animation: appear 320ms ease-out;
}
Keep motion brief and functional. Confirm the result in both the Previewer and Android build.