46. Toolbars, Icons and Printing¶
This chapter covers two kinds of output: what a program shows in its toolbars, and what it sends to the printer. The toolbar icons are drawn in Java2D instead of loaded from files, so they follow the theme and scale to any size; the printing parts decide the paper and where the pages go, while the program draws them.
Icons drawn in code: ToolIcon¶
Package swing · introduced in 30. Fractals · source: ToolIcon
What it is for. Toolbar icons that need no icon theme, look sharp at every scale and change colour with the desktop theme. Lines are drawn in the button's text colour (the inactive text colour when the button is disabled), paper in the field colour, highlights in the selection colour, so an icon on Dark Slate is as readable as on Warm Motif.
What is drawn is a glyph, a function that draws on a 16 × 16 grid:
/** Draws one icon on the 16-unit grid with the colours of the {@link Pen}. */
@FunctionalInterface
public interface Glyph {
void paint(Pen pen);
}
When to choose it. For every toolbar and transport button. The commands most programs share are in StandardGlyph; a program adds its own as an enum that implements Glyph, as Fractals (FractalGlyph), Imagescan (ImagescanGlyph) and WritePad (WritepadGlyph, TextColorGlyph) do. Icons of files and programs in a list come from the icon theme instead, because there the user expects the desktop's pictures.
How it works. paintIcon reads the theme at the moment it paints, scales the graphics from 16 units to the icon's size (22 pixels in a toolbar at 100 %) and hands the glyph a Pen:
@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
DesktopTheme theme = AmadeUI.theme();
boolean enabled = c == null || c.isEnabled();
Color ink = !enabled ? theme.textInactive()
: c != null && c.getForeground() != null ? c.getForeground() : theme.textActive();
Color light = enabled ? theme.selectionBackground() : theme.textInactive();
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
g2.translate(x, y);
g2.scale(size / 16.0, size / 16.0);
g2.setStroke(Pen.THIN);
glyph.paint(new Pen(g2, ink, theme.fieldBackground(), light, enabled));
} finally {
g2.dispose();
}
}
The Pen holds the graphics, the colours and the shapes several glyphs share:
| Method | Draws |
|---|---|
ink(), paper(), light(), enabled() |
The colours, and whether the button is enabled |
fill(shape, color), draw(shape), fillAndDraw(shape, fill) |
Any shape, outlined in ink |
line(x1, y1, x2, y2), bold(width), thin() |
Lines; bold with round ends until thin |
triangle(...) |
A filled arrow head |
page(x, y, w, h) |
A sheet with a folded corner |
magnifier(cx, cy, r) |
A lens with its handle |
letter(text, font, baseline) |
A letter, centred |
mirror() |
Left and right swapped, so Redo is Undo's mirror image |
Tip: half units
A 1-unit line on a whole number lies between two pixel rows and is drawn grey and two pixels wide. On half units (1.5, 2.5, ...) it covers one row exactly and stays crisp at 100 %.
Test: an application's glyph gets the pen with the theme's colours
@Test
void anApplicationGlyphGetsThePenWithTheThemeColours() {
AtomicReference<ToolIcon.Pen> seen = new AtomicReference<>();
JButton button = new JButton();
button.setForeground(new Color(0x123456));
paint(new ToolIcon(seen::set), button);
ToolIcon.Pen pen = seen.get();
assertEquals(new Color(0x123456), pen.ink());
assertEquals(AmadeUI.theme().fieldBackground(), pen.paper());
assertEquals(AmadeUI.theme().selectionBackground(), pen.light());
assertTrue(pen.enabled());
button.setEnabled(false);
paint(new ToolIcon(seen::set), button);
assertFalse(seen.get().enabled());
assertEquals(AmadeUI.theme().textInactive(), seen.get().light());
}
The shared symbols: StandardGlyph¶
Package swing · introduced in 30. Fractals · source: StandardGlyph
What it is for. The glyphs of the commands most programs share: NEW, OPEN, SAVE, PRINT, CUT, COPY, PASTE, UNDO, REDO, FIND, ZOOM_IN, ZOOM_OUT, REFRESH; for lists of records EDIT, DELETE, IMPORT, EXPORT and MAIL; for pictures ZOOM_FIT, ZOOM_ACTUAL, ROTATE_LEFT, ROTATE_RIGHT, FULL_SCREEN and LEAVE_FULL_SCREEN.
The second and third row came with 25. Contacts: Imagescan, Image Viewer and Fractals each had drawn their own turn, fit and full-screen arrows, and Contacts needed Export like Fractals. Now all four use these.
@Override
public void paint(ToolIcon.Pen pen) {
switch (this) {
case NEW -> pen.page(3.5, 1.5, 10, 13);
case OPEN -> open(pen);
case SAVE -> save(pen);
case PRINT -> print(pen);
case CUT -> cut(pen);
case COPY -> {
pen.page(1.5, 1.5, 8, 10);
pen.page(6.5, 4.5, 8, 10);
}
case PASTE -> paste(pen);
case UNDO -> undo(pen);
case REDO -> {
pen.mirror();
undo(pen);
}
case FIND -> pen.magnifier(6.5, 6.5, 4.5);
case ZOOM_IN -> {
pen.magnifier(6.5, 6.5, 5);
pen.line(4, 6.5, 9, 6.5);
pen.line(6.5, 4, 6.5, 9);
}
case ZOOM_OUT -> {
pen.magnifier(6.5, 6.5, 5);
pen.line(4, 6.5, 9, 6.5);
}
case REFRESH -> refresh(pen);
case EDIT -> edit(pen);
case DELETE -> delete(pen);
case IMPORT -> tray(pen, true);
case EXPORT -> tray(pen, false);
case MAIL -> mail(pen);
case ZOOM_FIT -> zoomFit(pen);
case ZOOM_ACTUAL -> {
pen.magnifier(6.5, 6.5, 5);
pen.g().setColor(pen.ink());
pen.g().setFont(pen.g().getFont().deriveFont(Font.BOLD, 6f));
pen.g().drawString("1:1", 3.2f, 8.6f);
}
case ROTATE_LEFT -> rotate(pen);
case ROTATE_RIGHT -> {
pen.mirror();
rotate(pen);
}
case FULL_SCREEN -> fullScreen(pen);
case LEAVE_FULL_SCREEN -> {
pen.fillAndDraw(new Rectangle2D.Double(1.5, 2.5, 13, 11), pen.paper());
pen.fillAndDraw(new Rectangle2D.Double(5.5, 6.5, 5, 3), pen.light());
}
}
}
When to choose it. For these commands in every program, so that Save looks the same everywhere. A glyph only one program needs stays in that program, and moves here when a second one needs it.
How it works. Each glyph is a few shapes from the Pen. The folder of Open shows the style: paper for the back, ink for the front flap, every line on half units.
/** A folder, its front flap open. */
private static void open(ToolIcon.Pen pen) {
Path2D back = new Path2D.Double();
back.moveTo(1.5, 13.5);
back.lineTo(1.5, 3.5);
back.lineTo(5.5, 3.5);
back.lineTo(7, 5.5);
back.lineTo(12.5, 5.5);
back.lineTo(12.5, 8.5);
pen.fillAndDraw(back, pen.paper());
Path2D front = new Path2D.Double();
front.moveTo(1.5, 13.5);
front.lineTo(4, 8.5);
front.lineTo(15, 8.5);
front.lineTo(12.5, 13.5);
front.closePath();
pen.fillAndDraw(front, pen.ink());
}
Import and Export are one drawing: a tray with an arrow that points into it or out of it.
/** An open tray with an arrow: into it for import, out of it for export. */
private static void tray(ToolIcon.Pen pen, boolean in) {
Path2D tray = new Path2D.Double();
tray.moveTo(1.5, 9.5);
tray.lineTo(1.5, 14.5);
tray.lineTo(14.5, 14.5);
tray.lineTo(14.5, 9.5);
tray.lineTo(11.5, 9.5);
tray.lineTo(10.5, 11.5);
tray.lineTo(5.5, 11.5);
tray.lineTo(4.5, 9.5);
tray.closePath();
pen.fillAndDraw(tray, pen.paper());
pen.bold(2f);
pen.line(8, in ? 0.5 : 10, 8, in ? 6 : 4.5);
pen.thin();
if (in) {
pen.triangle(4.5, 5.5, 11.5, 5.5, 8, 10);
} else {
pen.triangle(4.5, 5, 11.5, 5, 8, 0.5);
}
}
Toolbar buttons: ToolButtons¶
Package swing · introduced in 30. Fractals · source: ToolButtons
What it is for. Square Motif buttons for a toolbar, raised in 3D like the Front Panel's: icon only, the tooltip from the action, sunken while pressed or, for a switch, while it is on.
Action save = ...; // LARGE_ICON_KEY: new ToolIcon(StandardGlyph.SAVE)
toolbar.add(ToolButtons.button(save));
toolbar.add(ToolButtons.toggle(showGrid)); // an action with SELECTED_KEY
When to choose it. For toolbars whose buttons repeat menu commands. The menu and the toolbar share one Action, so both are enabled, disabled and switched together.
How it works. The buttons never take the focus, so the keyboard stays in the document after a click. Their border is set explicitly and is not a UIResource, because the toolbar's look would otherwise swap in its flat rollover border. The button's name is the action command, so tests find it.
private static <B extends AbstractButton> B configure(B button, Action action) {
button.setHideActionText(true);
button.setFocusable(false);
button.setMargin(new Insets(2, 2, 2, 2));
// Not a UIResource, so the toolbar's look and feel does not swap in its flat rollover border.
button.setBorder(BorderFactory.createCompoundBorder(new AmadeUI.MotifButtonBorder(2),
BorderFactory.createEmptyBorder()));
button.setName(String.valueOf(action.getValue(Action.ACTION_COMMAND_KEY)));
return button;
}
A player's buttons: TransportButtons¶
Package swing · introduced in 33. Audio · source: TransportButtons, TransportGlyph
What it is for. The wide buttons of a player's transport bar: Previous, Next, Rewind, Forward, Stop, Play, Pause, Record and Eject. Audio and Video use them.
TransportButtons transport = TransportButtons.NORMAL; // or new TransportButtons(0.8)
JButton play = transport.button(TransportGlyph.PLAY, presenter::play);
panel.add(TransportButtons.bar(previous, stop, play, pause, next));
When to choose it. For the main controls of a player. Unlike toolbar buttons they take the focus, because in a player they are the controls, not shortcuts for menu commands.
How it works. TransportGlyph draws filled shapes in the button's text colour; the record dot is red while the button is enabled. Next and Forward are drawn as mirror images of Previous and Rewind:
@Override
public void paint(ToolIcon.Pen pen) {
switch (this) {
case PREVIOUS -> {
bar(pen, 2, 2, 2, 12);
pen.triangle(14, 2, 14, 14, 5, 8);
}
case NEXT -> {
pen.mirror();
PREVIOUS.paint(pen);
}
case REWIND -> {
pen.triangle(8, 2, 8, 14, 1, 8);
pen.triangle(15, 2, 15, 14, 8, 8);
}
case FORWARD -> {
pen.mirror();
REWIND.paint(pen);
}
case STOP -> bar(pen, 3, 3, 10, 10);
case PLAY -> pen.triangle(4, 2, 4, 14, 14, 8);
case PAUSE -> {
bar(pen, 3, 2, 4, 12);
bar(pen, 9, 2, 4, 12);
}
case RECORD -> pen.fill(new Ellipse2D.Double(2, 2, 12, 12), pen.enabled() ? RECORD_RED : pen.ink());
case EJECT -> {
pen.triangle(8, 2, 15, 9, 1, 9);
bar(pen, 1, 11, 14, 3);
}
}
}
The tooltip comes from the program's texts under glyph.key(), transport.play and so on, because the same symbol means different things: Previous is the previous track in Audio and the previous chapter in Video. A program that uses a glyph must have its text; TranslationCheck finds the missing ones. The sizes scale with one factor, 50 × 34 pixels and a 16-pixel symbol at 100 %:
/** The symbol: 16 pixels at 100 %, never less than 10. */
public ToolIcon icon(TransportGlyph glyph) {
return new ToolIcon(glyph, Math.max(10, px(16)));
}
/** A push button that runs the action. */
public JButton button(TransportGlyph glyph, Runnable action) {
JButton b = style(new JButton(icon(glyph)), glyph);
b.addActionListener(e -> action.run());
return b;
}
/** A button that stays pressed in, e.g. Record. */
public JToggleButton toggle(TransportGlyph glyph) {
return style(new JToggleButton(icon(glyph)), glyph);
}
private <B extends AbstractButton> B style(B b, TransportGlyph glyph) {
b.setToolTipText(tr(glyph.key()));
b.getAccessibleContext().setAccessibleName(tr(glyph.key()));
b.setMargin(new Insets(px(6), px(14), px(6), px(14)));
Dimension size = new Dimension(px(50), px(34));
b.setPreferredSize(size);
b.setMinimumSize(size);
b.setName(glyph.key());
return b;
}
bar puts buttons side by side without gaps, as one bar.
Test: Next is the mirror image of Previous
@Test
void nextIsTheMirrorImageOfPrevious() {
JButton button = new JButton();
button.setForeground(Color.BLACK);
BufferedImage previous = paint(new ToolIcon(TransportGlyph.PREVIOUS, 16), button);
BufferedImage next = paint(new ToolIcon(TransportGlyph.NEXT, 16), button);
for (int y = 0; y < 16; y++) {
for (int x = 0; x < 16; x++) {
assertEquals(previous.getRGB(x, y), next.getRGB(15 - x, y), "pixel " + x + "," + y);
}
}
}
Paper: PaperSize¶
Package print · introduced in 23. WritePad · source: PaperSize
What it is for. The paper sizes the programs offer, A5, A4, LETTER and LEGAL, in points (1/72 inch), the unit of java.awt.print and of the document formats. media() is the paper the printer should take.
When to choose it. As the default of every paper setting: forLocale gives Letter where Letter is the usual paper and A4 everywhere else.
/** The usual paper where the user lives: Letter in North America and a few other countries, else A4. */
public static PaperSize forLocale(Locale locale) {
return LETTER_COUNTRIES.contains(locale.getCountry()) ? LETTER : A4;
}
How it works. The sizes are kept in millimetres, as the standards define them, and converted to points when asked. The country, not the language, decides: English in Germany is A4.
Printing: PrintJobs¶
Package print · introduced in 21. Calendar, moved into amade-commons with 23. WritePad · source: PrintJobs
What it is for. Sending a program's pages somewhere: to a printer through the system's print dialog, or to a PostScript file. The pages are a Pageable, usually a java.awt.print.Book, and decide what is drawn; PrintJobs only decides where it goes. Calendar and WritePad print this way.
| Method | What it does |
|---|---|
pageFormat(paper, landscape, margin) |
A page format for the paper; margin 0 when the pages lay out their own |
attributes(paper, jobName) |
The paper and the job name for the printer |
printWithDialog(pages, paper, jobName) |
The print dialog, then printing; false if the user cancelled |
writePostScript(pages, file, jobName) |
A PostScript file, without any printer |
printerNames() |
The printers the system knows; empty without a print system |
How it works. A landscape page keeps the paper in portrait and turns the graphics. The printer is not asked for landscape as well, because some drivers then turn the page twice:
/**
* A page format for the paper.
*
* @param landscape true to turn the sheet; the paper stays in portrait and the printer turns the graphics
* @param margin the margin on every side, in points, outside which the pages do not draw; 0 when the
* pages lay out their own margins
*/
public static PageFormat pageFormat(PaperSize paper, boolean landscape, double margin) {
Objects.requireNonNull(paper, "paper");
double width = paper.width();
double height = paper.height();
if (margin < 0 || 2 * margin >= Math.min(width, height)) {
throw new IllegalArgumentException("Margin does not fit the paper: " + margin);
}
Paper sheet = new Paper();
sheet.setSize(width, height);
sheet.setImageableArea(margin, margin, width - 2 * margin, height - 2 * margin);
PageFormat format = new PageFormat();
format.setPaper(sheet);
format.setOrientation(landscape ? PageFormat.LANDSCAPE : PageFormat.PORTRAIT);
return format;
}
/**
* The paper and the job name for the printer. The orientation is deliberately not requested: the page
* format already turns landscape pages, and asking the printer as well turns them twice on some drivers.
*/
public static PrintRequestAttributeSet attributes(PaperSize paper, String jobName) {
PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet();
attributes.add(paper.media());
attributes.add(new JobName(jobName, null));
return attributes;
}
PostScript goes through the JDK's own stream printer, which needs no printer and no CUPS. That makes printing testable on a build server: a test writes a file and checks the pages in it.
/**
* Writes the pages to a PostScript file through the JDK's own PostScript stream printer, so it works
* without any printer or print system installed.
*/
public static void writePostScript(Pageable pages, Path file, String jobName) throws PrinterException, IOException {
StreamPrintServiceFactory[] factories = StreamPrintServiceFactory.lookupStreamPrintServiceFactories(
DocFlavor.SERVICE_FORMATTED.PAGEABLE, POSTSCRIPT);
if (factories.length == 0) throw new PrinterException("This Java has no PostScript printer");
try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(file))) {
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(factories[0].getPrintService(out));
job.setJobName(jobName);
job.setPageable(pages);
PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet();
attributes.add(new JobName(jobName, null));
job.print(attributes);
}
}
Pitfall: Swing components on the printing thread
When the pages paint Swing components, as WritePad's text pages do, printWithDialog must run on the EDT, like any other access to them. Pages that draw only with Java2D may print on a background thread.
Test: landscape turns the format, not the paper
@Test
void landscapeTurnsTheFormatButNotThePaper() {
PageFormat format = PrintJobs.pageFormat(PaperSize.A5, true, 0);
assertEquals(PageFormat.LANDSCAPE, format.getOrientation());
assertEquals(PaperSize.A5.height(), format.getWidth(), 0.01);
assertEquals(PaperSize.A5.width(), format.getHeight(), 0.01);
assertEquals(PaperSize.A5.width(), format.getPaper().getWidth(), 0.01);
}
Test: PostScript without a printer
@Test
void writesPostScriptWithoutAPrinter() throws Exception {
Path file = Files.createTempFile("print-jobs-", ".ps");
try {
Book book = new Book();
Printable page = (graphics, format, index) -> {
((Graphics2D) graphics).drawString("Hello", 100, 100);
return Printable.PAGE_EXISTS;
};
book.append(page, PrintJobs.pageFormat(PaperSize.A4, false, 36), 2);
PrintJobs.writePostScript(book, file, "test");
String ps = Files.readString(file);
assertTrue(ps.startsWith("%!PS"), ps.substring(0, Math.min(40, ps.length())));
assertTrue(ps.contains("%%Page: 2 2") && !ps.contains("%%Page: 3"), "two pages");
} finally {
Files.deleteIfExists(file);
}
}