display enhancement

This commit is contained in:
Bane Stojkovic
2026-10-04 20:51:32 +02:00
parent 4ee3335c18
commit da5184a226
13 changed files with 538 additions and 12 deletions
@@ -167,6 +167,10 @@ public abstract class AbstractMakerBoard extends AbstractTransparentComponent<Vo
// the rotated ones and sits closer to the pin, which is all the room a shallow strip has
public static Font PIN_ROW_FLAT_FONT = new Font(SILK_FONT_FAMILY, Font.PLAIN, 8);
public static Size PIN_ROW_FLAT_LABEL_OFFSET = new Size(2.04d, SizeUnit.mm);
// Clearance kept between text printed on a lit panel and the panel's edge. A readout is drawn
// inside the glass rather than on board material, so unlike a silk label it has no neighbour to
// collide with; the margin only stops the longest line from touching the frame.
public static Size SCREEN_TEXT_MARGIN = new Size(0.6d, SizeUnit.mm);
protected Orientation orientation = Orientation.DEFAULT;
protected Point2D[] controlPoints = new Point2D[] {new Point2D.Double(0, 0)};
@@ -22,13 +22,16 @@
package org.diylc.components;
import java.awt.Color;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Path2D;
import java.awt.geom.Rectangle2D;
import java.awt.geom.RoundRectangle2D;
import java.util.List;
import org.diylc.awt.StringUtils;
import org.diylc.common.Display;
import org.diylc.common.HorizontalAlignment;
import org.diylc.common.ObjectCache;
import org.diylc.common.VerticalAlignment;
@@ -298,4 +301,77 @@ public class MakerBoardPainter {
g2d.rotate(-rotation, cx, cy);
}
}
/**
* Prints a display's own description on its lit area, wrapped to the area and centred in it.
*
* <p>What it prints is what the component already answers for the BOM, so a variant is stated
* once and the glass cannot fall out of step with the part list. The point of it is
* identification: six OLED boards are the same dark letterbox on the same blue PCB, and the
* property editor, the BOM and the Explorer pane are all off the canvas.
*
* <p>Nothing is drawn unless the whole block fits, so a panel too small to hold its description
* shows a bare screen rather than a crop -- the same rule the pin-name silkscreen follows.
*
* <p>The area is the part of the panel the text may occupy rather than the panel itself, so a
* round display hands over the square inscribed in its glass.
*/
public static void drawScreenText(Graphics2D g2d, Rectangle2D area, Color inkColor,
Display display, String name, String value) {
String text = screenText(display, name, value);
if (text == null) {
return;
}
g2d.setFont(AbstractMakerBoard.SILK_FONT);
FontMetrics metrics = g2d.getFontMetrics();
double margin = AbstractMakerBoard.SCREEN_TEXT_MARGIN.convertToPixels();
double maxWidth = area.getWidth() - 2 * margin;
double maxHeight = area.getHeight() - 2 * margin;
double lineHeight = metrics.getHeight();
List<String> lines = StringUtils.wrap(text, metrics, (int) maxWidth);
if (lines.isEmpty() || lines.size() * lineHeight > maxHeight) {
return;
}
// a single word wider than the area cannot be broken, so the width is checked after wrapping
// rather than trusted to it
for (String line : lines) {
if (metrics.stringWidth(line) > maxWidth) {
return;
}
}
g2d.setColor(inkColor);
double y = area.getCenterY() - (lines.size() - 1) * lineHeight / 2.0;
for (String line : lines) {
StringUtils.drawCenteredText(g2d, line, area.getCenterX(), y, HorizontalAlignment.CENTER,
VerticalAlignment.CENTER);
y += lineHeight;
}
}
/**
* What each {@link Display} setting puts on the glass, or {@code null} for a screen that prints
* nothing. The variant string reads "size, interface", and breaking it at the comma gives one
* property per line, which keeps the longest line well inside the glass instead of spanning it.
*/
private static String screenText(Display display, String name, String value) {
boolean hasValue = value != null && !value.trim().isEmpty();
String variant = hasValue ? value.replace(", ", "\n") : null;
if (display == null) {
return variant;
}
switch (display) {
case NONE:
return null;
case NAME:
return name;
case BOTH:
return variant == null ? name : name + "\n" + variant;
default:
return variant;
}
}
}
@@ -32,6 +32,7 @@ import java.awt.geom.Rectangle2D;
import java.awt.geom.RoundRectangle2D;
import org.diylc.awt.StringUtils;
import org.diylc.common.Display;
import org.diylc.common.HorizontalAlignment;
import org.diylc.common.ObjectCache;
import org.diylc.common.Orientation;
@@ -48,6 +49,7 @@ import org.diylc.core.annotations.EditableProperty;
import org.diylc.core.annotations.KeywordPolicy;
import org.diylc.core.measures.Size;
import org.diylc.core.measures.SizeUnit;
import org.diylc.presenter.CalcUtils;
import org.diylc.utils.Constants;
@ComponentDescriptor(name = "Character LCD", category = "Displays & Outputs",
@@ -75,6 +77,10 @@ public class CharacterLCD extends AbstractMakerBoard {
public static Color PCB_GREEN = Color.decode("#1B5E20");
public static Color SCREEN_BG = Color.decode("#1E88E5");
public static Color BEZEL_COLOR = Color.decode("#212121");
// Both inks are real: the blue-backlit module shows light characters and the yellow-green one
// dark ones, so which is used follows the screen colour rather than being fixed.
public static Color SCREEN_INK_LIGHT = Color.decode("#F5F5F5");
public static Color SCREEN_INK_DARK = Color.decode("#1B2631");
public static final String[] PIN_NAMES_I2C = new String[] {"GND", "VCC", "SDA", "SCL"};
public static final String[] PIN_NAMES_PARALLEL = new String[] {
@@ -94,6 +100,7 @@ public class CharacterLCD extends AbstractMakerBoard {
private LCDSize lcdSize = LCDSize._16x2;
private LCDInterface lcdInterface = LCDInterface.I2C_Backpack;
private Color screenColor = SCREEN_BG;
private Display screen = Display.VALUE;
public CharacterLCD() {
super();
@@ -123,6 +130,21 @@ public class CharacterLCD extends AbstractMakerBoard {
invalidateCache();
}
/**
* What the lit area prints. The two sizes differ in outline, but which of them a module is and
* which interface it carries are what a reader needs and neither is legible from the drawing;
* {@code NONE} leaves the screen blank.
*/
@EditableProperty
public Display getScreen() {
return screen == null ? Display.VALUE : screen;
}
public void setScreen(Display screen) {
this.screen = screen;
invalidateCache();
}
// Both properties decide which part you buy, so the BOM carries them together.
@Override
protected String getVariantLabel() {
@@ -168,6 +190,11 @@ public class CharacterLCD extends AbstractMakerBoard {
: PIN_NAMES_PARALLEL.length;
}
/** The ink that contrasts with the backlight the user has chosen. */
private Color getScreenInk() {
return CalcUtils.calculateLuminance(screenColor) < 128d ? SCREEN_INK_LIGHT : SCREEN_INK_DARK;
}
private double getBoardWidth() {
return new Size(lcdSize.getWidthMm(), SizeUnit.mm).convertToPixels();
}
@@ -289,6 +316,10 @@ public class CharacterLCD extends AbstractMakerBoard {
g2d.setColor(screenColor);
g2d.fill(new Rectangle2D.Double(screenX, screenY, screenW, screenH));
MakerBoardPainter.drawScreenText(g2d,
new Rectangle2D.Double(screenX, screenY, screenW, screenH), getScreenInk(), getScreen(),
getName(), getValueForDisplay());
// Only the parallel row has anywhere to print. Its names go in the strip between the row and
// the bezel, which is 2.9 mm deep. The backpack's four pins stand in a column 2.5 mm from
// the left edge with the bezel beginning 1.4 mm further in, so a label beside them would lie
@@ -31,6 +31,7 @@ import java.awt.geom.Rectangle2D;
import java.awt.geom.RoundRectangle2D;
import org.diylc.awt.StringUtils;
import org.diylc.common.Display;
import org.diylc.common.HorizontalAlignment;
import org.diylc.common.ObjectCache;
import org.diylc.common.Orientation;
@@ -43,6 +44,7 @@ import org.diylc.core.IDrawingObserver;
import org.diylc.core.Project;
import org.diylc.core.annotations.BomPolicy;
import org.diylc.core.annotations.ComponentDescriptor;
import org.diylc.core.annotations.EditableProperty;
import org.diylc.core.annotations.KeywordPolicy;
import org.diylc.core.measures.Size;
import org.diylc.core.measures.SizeUnit;
@@ -63,6 +65,7 @@ public class Nokia5110LCD extends AbstractMakerBoard {
public static Color BEZEL_BORDER_COLOR = LIGHT_METAL_COLOR.darker();
public static Color LCD_COLOR = Color.decode("#6B7668");
public static Color LCD_BORDER_COLOR = LCD_COLOR.darker();
public static Color LCD_INK_COLOR = Color.decode("#2B2B2B");
public static Size BOARD_WIDTH = new Size(43.8d, SizeUnit.mm);
public static Size BOARD_LENGTH = new Size(45.8d, SizeUnit.mm);
@@ -91,12 +94,38 @@ public class Nokia5110LCD extends AbstractMakerBoard {
public static final String[] PIN_NAMES =
new String[] {"RST", "CE", "DC", "DIN", "CLK", "VCC", "BL", "GND"};
private Display screen = Display.VALUE;
public Nokia5110LCD() {
super();
this.bodyColor = PCB_BLUE;
updateControlPoints();
}
/**
* What the lit area prints. {@code NONE} leaves the glass blank, which is what the module looks
* like unpowered.
*/
@EditableProperty
public Display getScreen() {
return screen == null ? Display.VALUE : screen;
}
public void setScreen(Display screen) {
this.screen = screen;
invalidateCache();
}
/**
* There is only one of these modules, so unlike the boards with a variant property this is not
* needed to keep the BOM's rows apart. It is still the better answer than an empty value column,
* and it is what the glass prints.
*/
@Override
protected String getVariantLabel() {
return "84x48 PCD8544";
}
@Override
public String getControlPointNodeName(int index) {
if (index >= 0 && index < PIN_NAMES.length) {
@@ -217,6 +246,10 @@ public class Nokia5110LCD extends AbstractMakerBoard {
g2d.draw(new RoundRectangle2D.Double(displayX, displayY, displayW, displayH, displayRadius,
displayRadius));
MakerBoardPainter.drawScreenText(g2d,
new Rectangle2D.Double(displayX, displayY, displayW, displayH), LCD_INK_COLOR,
getScreen(), getName(), getValueForDisplay());
// The module prints its pin names in the strip below the header. They lie flat along the row
// rather than standing on end across it the way the Arduino boards print theirs, because the
// strip is 3.5 mm deep and a label stood on end needs 4.7 mm: one label offset plus the
@@ -32,6 +32,7 @@ import java.awt.geom.Rectangle2D;
import java.awt.geom.RoundRectangle2D;
import org.diylc.awt.StringUtils;
import org.diylc.common.Display;
import org.diylc.common.HorizontalAlignment;
import org.diylc.common.ObjectCache;
import org.diylc.common.Orientation;
@@ -71,6 +72,7 @@ public class OLEDDisplay extends AbstractMakerBoard {
private Version version = Version.SSD1306_0_96;
private OLEDInterface oledInterface = OLEDInterface.I2C_4Pin;
private Display screen = Display.VALUE;
public OLEDDisplay() {
super();
@@ -100,6 +102,21 @@ public class OLEDDisplay extends AbstractMakerBoard {
invalidateCache();
}
/**
* What the lit area prints. Six boards share this outline and this colour, so the glass carries
* the size, the controller and the interface to tell them apart; {@code NONE} leaves the panel
* dark for a drawing that wants the unpowered part.
*/
@EditableProperty
public Display getScreen() {
return screen == null ? Display.VALUE : screen;
}
public void setScreen(Display screen) {
this.screen = screen;
invalidateCache();
}
// Both properties decide which part you buy, so the BOM carries them together.
@Override
protected String getVariantLabel() {
@@ -251,9 +268,14 @@ public class OLEDDisplay extends AbstractMakerBoard {
// than of the board it is mounted on, so the two 0.91" boards share them.
double activeW = px(version.getActiveWidthMm());
double activeH = px(version.getActiveLengthMm());
double activeX = glassX + px(version.getActiveLeftInPanelMm());
double activeY = glassY + (glassH - activeH) / 2.0;
g2d.setColor(ACTIVE_AREA_COLOR);
g2d.fill(new RoundRectangle2D.Double(glassX + px(version.getActiveLeftInPanelMm()),
glassY + (glassH - activeH) / 2.0, activeW, activeH, 2, 2));
g2d.fill(new RoundRectangle2D.Double(activeX, activeY, activeW, activeH, 2, 2));
MakerBoardPainter.drawScreenText(g2d,
new Rectangle2D.Double(activeX, activeY, activeW, activeH), PIXEL_BLUE, getScreen(),
getName(), getValueForDisplay());
}
g2d.setTransform(oldTx);
@@ -34,6 +34,7 @@ import java.awt.geom.Rectangle2D;
import java.awt.geom.RoundRectangle2D;
import org.diylc.awt.StringUtils;
import org.diylc.common.Display;
import org.diylc.common.HorizontalAlignment;
import org.diylc.common.ObjectCache;
import org.diylc.common.Orientation;
@@ -76,10 +77,12 @@ public class TFTDisplay extends AbstractMakerBoard {
public static Color TFT_RED = Color.decode("#C0392B");
public static Color SCREEN_BG = Color.decode("#111111");
public static Color GLASS_COLOR = Color.decode("#1A1A1A");
public static Color SCREEN_INK = Color.decode("#E0E0E0");
public static Size CORNER_RADIUS = new Size(1.5d, SizeUnit.mm);
private Controller controller = Controller.ILI9341_2_8;
private Display screen = Display.VALUE;
public TFTDisplay() {
super();
@@ -98,6 +101,21 @@ public class TFTDisplay extends AbstractMakerBoard {
invalidateCache();
}
/**
* What the lit area prints. The four controllers differ in diagonal, resolution and shape but
* not in colour, and the 1.8" and 1.54" boards are within 2 mm of each other, so the panel
* carries the description; {@code NONE} leaves it dark.
*/
@EditableProperty
public Display getScreen() {
return screen == null ? Display.VALUE : screen;
}
public void setScreen(Display screen) {
this.screen = screen;
invalidateCache();
}
@Override
protected String getVariantLabel() {
return getController().toString();
@@ -246,7 +264,7 @@ public class TFTDisplay extends AbstractMakerBoard {
double screenW = px(controller.getScreenWidthMm());
double screenH = px(controller.getScreenLengthMm());
Shape screen;
Shape panel;
if (controller.isRound()) {
// the lit circle is centred on the disc, which sits above the tab rather than on the
@@ -254,7 +272,7 @@ public class TFTDisplay extends AbstractMakerBoard {
double discR = boardW / 2.0;
double discCx = boardX + discR;
double discCy = boardY + discR;
screen = new Ellipse2D.Double(discCx - screenW / 2.0, discCy - screenH / 2.0, screenW,
panel = new Ellipse2D.Double(discCx - screenW / 2.0, discCy - screenH / 2.0, screenW,
screenH);
} else {
double bandCentre = (bandTop + bandBottom) / 2.0;
@@ -268,15 +286,26 @@ public class TFTDisplay extends AbstractMakerBoard {
} else {
screenY = bandCentre - screenH / 2.0;
}
screen = new Rectangle2D.Double(boardX + (boardW - screenW) / 2.0, screenY, screenW,
panel = new Rectangle2D.Double(boardX + (boardW - screenW) / 2.0, screenY, screenW,
screenH);
}
g2d.setColor(SCREEN_BG);
g2d.fill(screen);
g2d.fill(panel);
g2d.setColor(Color.DARK_GRAY);
g2d.setStroke(ObjectCache.getInstance().fetchBasicStroke(1));
g2d.draw(screen);
g2d.draw(panel);
// Only the square inscribed in a round panel is safely inside it, so the disc hands over
// that rather than the bounding box every other variant can use whole.
Rectangle2D textArea = panel.getBounds2D();
if (controller.isRound()) {
double side = textArea.getWidth() / Math.sqrt(2.0);
textArea = new Rectangle2D.Double(textArea.getCenterX() - side / 2.0,
textArea.getCenterY() - side / 2.0, side, side);
}
MakerBoardPainter.drawScreenText(g2d, textArea, SCREEN_INK, getScreen(), getName(),
getValueForDisplay());
// The names go in the strip between the pin row and the panel, lying flat along the row as
// the Nokia's do: the strip is 3.5 mm deep on the two smallest boards and a label stood on
@@ -0,0 +1,108 @@
/*
*
* DIY Layout Creator (DIYLC).
* Copyright (c) 2009-2025 held jointly by the individual authors.
*
* This file is part of DIYLC.
*
* DIYLC is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* DIYLC is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.diylc.components.displays;
import java.awt.Color;
import org.diylc.appframework.miscutils.ConfigurationManager;
import org.diylc.common.Display;
import org.diylc.components.displays.CharacterLCD.LCDInterface;
import org.diylc.components.displays.CharacterLCD.LCDSize;
import org.diylc.components.maker.MakerBoardTestSupport;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
/**
* What the character LCD prints on its screen. The module's two sizes and two interfaces are the
* whole of what a buyer chooses between, and neither is legible from a green board with a blue
* window, so the lit area carries them.
*/
public class CharacterLCDTest {
@BeforeClass
public static void setUp() {
try {
ConfigurationManager.getInstance().initialize("diylc");
} catch (Exception ignored) {
}
}
private static CharacterLCD of(LCDSize lcdSize, LCDInterface lcdInterface) {
CharacterLCD display = new CharacterLCD();
display.setLcdSize(lcdSize);
display.setLcdInterface(lcdInterface);
return display;
}
@Test
public void everyScreenPrintsItsSizeAndInterface() {
for (LCDSize lcdSize : LCDSize.values()) {
for (LCDInterface lcdInterface : LCDInterface.values()) {
CharacterLCD printed = of(lcdSize, lcdInterface);
CharacterLCD blank = of(lcdSize, lcdInterface);
blank.setScreen(Display.NONE);
Assert.assertEquals(Display.VALUE, printed.getScreen());
Assert.assertEquals(lcdSize + ", " + lcdInterface, printed.getValueForDisplay());
MakerBoardTestSupport.assertScreenTextIsDrawn(printed, blank);
}
}
}
/**
* Both inks exist on real modules -- the blue-backlit one shows light characters and the
* yellow-green one dark -- so the text has to survive a screen colour the user picks. Two
* backlights at opposite ends of the range must not render the same, or one of them is printing
* its text in the backlight's own colour.
*/
@Test
public void theInkFollowsTheBacklight() {
CharacterLCD dark = new CharacterLCD();
dark.setScreenColor(Color.decode("#1E88E5"));
CharacterLCD pale = new CharacterLCD();
pale.setScreenColor(Color.decode("#CDDC39"));
CharacterLCD darkBlank = new CharacterLCD();
darkBlank.setScreenColor(Color.decode("#1E88E5"));
darkBlank.setScreen(Display.NONE);
CharacterLCD paleBlank = new CharacterLCD();
paleBlank.setScreenColor(Color.decode("#CDDC39"));
paleBlank.setScreen(Display.NONE);
MakerBoardTestSupport.assertScreenTextIsDrawn(dark, darkBlank);
MakerBoardTestSupport.assertScreenTextIsDrawn(pale, paleBlank);
}
@Test
public void drawsCleanlyInEveryState() {
for (LCDSize lcdSize : LCDSize.values()) {
for (LCDInterface lcdInterface : LCDInterface.values()) {
for (Display screen : Display.values()) {
CharacterLCD display = of(lcdSize, lcdInterface);
display.setScreen(screen);
MakerBoardTestSupport.assertDrawsCleanly(display);
}
}
}
}
}
@@ -25,7 +25,9 @@ import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import org.diylc.appframework.miscutils.ConfigurationManager;
import org.diylc.common.Display;
import org.diylc.components.AbstractMakerBoard;
import org.diylc.components.maker.MakerBoardTestSupport;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -149,4 +151,20 @@ public class Nokia5110LCDTest {
AbstractMakerBoard.PIN_ROW_FLAT_LABEL_OFFSET.convertToPixels() > AbstractMakerBoard.PIN_SIZE
.convertToPixels());
}
/**
* The one part in the package with no variant property still has a resolution and a controller
* worth printing, which is why it answers {@code getValueForDisplay} at all: without it the
* glass would be blank and the BOM's value column empty.
*/
@Test
public void theGlassPrintsTheResolutionAndController() {
Nokia5110LCD printed = new Nokia5110LCD();
Nokia5110LCD blank = new Nokia5110LCD();
blank.setScreen(Display.NONE);
Assert.assertEquals("84x48 PCD8544", printed.getValueForDisplay());
Assert.assertEquals(Display.VALUE, printed.getScreen());
MakerBoardTestSupport.assertScreenTextIsDrawn(printed, blank);
}
}
@@ -25,10 +25,12 @@ import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import org.diylc.appframework.miscutils.ConfigurationManager;
import org.diylc.common.Display;
import org.diylc.components.AbstractMakerBoard;
import org.diylc.components.displays.OLEDDisplay.Layout;
import org.diylc.components.displays.OLEDDisplay.OLEDInterface;
import org.diylc.components.displays.OLEDDisplay.Version;
import org.diylc.components.maker.MakerBoardTestSupport;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -434,4 +436,23 @@ public class OLEDDisplayTest {
Assert.assertEquals("every board should get its own BOM row",
Version.values().length * OLEDInterface.values().length, values.size());
}
/**
* The six boards are the same dark letterbox on the same blue PCB, so the lit area carries the
* description that tells them apart. It is rendered rather than asked for, because a screen that
* found no room for the text would still answer the getter.
*/
@Test
public void everyPanelPrintsItsDescription() {
for (Version version : Version.values()) {
for (OLEDInterface oledInterface : OLEDInterface.values()) {
OLEDDisplay printed = of(version, oledInterface);
OLEDDisplay blank = of(version, oledInterface);
blank.setScreen(Display.NONE);
Assert.assertEquals(Display.VALUE, printed.getScreen());
MakerBoardTestSupport.assertScreenTextIsDrawn(printed, blank);
}
}
}
}
@@ -26,7 +26,9 @@ import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import org.diylc.appframework.miscutils.ConfigurationManager;
import org.diylc.common.Display;
import org.diylc.components.displays.TFTDisplay.Controller;
import org.diylc.components.maker.MakerBoardTestSupport;
import org.diylc.core.measures.Size;
import org.diylc.core.measures.SizeUnit;
import org.junit.Assert;
@@ -350,4 +352,20 @@ public class TFTDisplayTest {
mm(fromEdge), 0.01d);
}
}
/**
* Every panel prints its description, the round one included -- it is the only variant whose
* text has to fit a circle, and it does so by being given the square inscribed in the glass.
*/
@Test
public void everyPanelPrintsItsDescription() {
for (Controller controller : Controller.values()) {
TFTDisplay printed = of(controller);
TFTDisplay blank = of(controller);
blank.setScreen(Display.NONE);
Assert.assertEquals(Display.VALUE, printed.getScreen());
MakerBoardTestSupport.assertScreenTextIsDrawn(printed, blank);
}
}
}
@@ -25,6 +25,7 @@ import java.awt.Graphics2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import org.diylc.common.Orientation;
import org.diylc.components.AbstractMakerBoard;
@@ -47,6 +48,10 @@ public class MakerBoardTestSupport {
public static final double PIN_SPACING = new Size(0.1d, SizeUnit.in).convertToPixels();
// large enough for the longest board in the package, the 86 mm 2.8" TFT, with its origin in the
// middle
private static final int RENDER_SIZE = 1400;
private static final Project PROJECT = new Project();
private static final IDrawingObserver OBSERVER = new IDrawingObserver() {
@@ -123,6 +128,34 @@ public class MakerBoardTestSupport {
}
}
/**
* Renders the board alone at a fixed position and returns the pixels. Two renderings of the same
* board that differ in one property can then be compared, which is the only way to catch a
* drawing change that moves no geometry -- what a board prints on its screen, for instance.
*/
public static int[] renderPixels(AbstractMakerBoard board) {
BufferedImage image = new BufferedImage(RENDER_SIZE, RENDER_SIZE, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = image.createGraphics();
board.setControlPoint(new Point2D.Double(RENDER_SIZE / 2.0, RENDER_SIZE / 2.0), 0);
try {
board.draw(g2d, ComponentState.NORMAL, false, PROJECT, OBSERVER);
} finally {
g2d.dispose();
}
return image.getRGB(0, 0, RENDER_SIZE, RENDER_SIZE, null, 0, RENDER_SIZE);
}
/**
* Asserts that a board prints its description on its lit area, and that turning the property off
* takes it away again. Comparing two renderings is what makes this a check on the drawing rather
* than on the property: a screen that silently refused to print would pass the getter.
*/
public static void assertScreenTextIsDrawn(AbstractMakerBoard withText,
AbstractMakerBoard withoutText) {
Assert.assertFalse("The lit area should carry the description, and does not",
Arrays.equals(renderPixels(withText), renderPixels(withoutText)));
}
/** Draws the board in every state and orientation; anything that throws fails the test. */
public static void assertDrawsCleanly(AbstractMakerBoard board) {
BufferedImage image = new BufferedImage(1000, 1000, BufferedImage.TYPE_INT_ARGB);
@@ -0,0 +1,103 @@
/*
*
* DIY Layout Creator (DIYLC).
* Copyright (c) 2009-2025 held jointly by the individual authors.
*
* This file is part of DIYLC.
*
* DIYLC is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* DIYLC is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.diylc.components.maker;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import org.diylc.common.Display;
import org.diylc.components.MakerBoardPainter;
import org.junit.Assert;
import org.junit.Test;
/**
* What {@code MakerBoardPainter.drawScreenText} promises, independently of the four displays that
* call it. The rule worth pinning down is that it prints nothing at all rather than a crop: a
* panel too small for its own description is left dark, the same way a pin name with nowhere to go
* is left off the silkscreen.
*/
public class ScreenTextTest {
private static final String VALUE = "0.96\" SSD1306 128x64, I2C";
private static int inkedPixels(Rectangle2D area, Display display, String name, String value) {
BufferedImage image = new BufferedImage(600, 400, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = image.createGraphics();
try {
MakerBoardPainter.drawScreenText(g2d, area, Color.WHITE, display, name, value);
} finally {
g2d.dispose();
}
int count = 0;
for (int rgb : image.getRGB(0, 0, 600, 400, null, 0, 600)) {
if ((rgb >>> 24) != 0) {
count++;
}
}
return count;
}
@Test
public void aPanelWithRoomPrintsItsDescription() {
Assert.assertTrue(
inkedPixels(new Rectangle2D.Double(10, 10, 400, 200), Display.VALUE, "DISP1", VALUE) > 0);
}
@Test
public void aPanelWithoutRoomPrintsNothing() {
Assert.assertEquals(0,
inkedPixels(new Rectangle2D.Double(10, 10, 24, 10), Display.VALUE, "DISP1", VALUE));
}
@Test
public void aPanelTooShallowForTheBlockPrintsNothing() {
// wide enough for every line, but with room for one of the two
Assert.assertEquals(0,
inkedPixels(new Rectangle2D.Double(10, 10, 400, 16), Display.VALUE, "DISP1", VALUE));
}
@Test
public void noneLeavesTheScreenDark() {
Assert.assertEquals(0,
inkedPixels(new Rectangle2D.Double(10, 10, 400, 200), Display.NONE, "DISP1", VALUE));
}
@Test
public void aPartWithNoVariantPrintsNothingForItsValue() {
Assert.assertEquals(0,
inkedPixels(new Rectangle2D.Double(10, 10, 400, 200), Display.VALUE, "DISP1", ""));
}
@Test
public void eachSettingPrintsSomethingDifferent() {
Rectangle2D area = new Rectangle2D.Double(10, 10, 400, 200);
int name = inkedPixels(area, Display.NAME, "DISP1", VALUE);
int value = inkedPixels(area, Display.VALUE, "DISP1", VALUE);
int both = inkedPixels(area, Display.BOTH, "DISP1", VALUE);
Assert.assertTrue(name > 0);
Assert.assertTrue(value > name);
Assert.assertTrue(both > value);
}
}
+35 -5
View File
@@ -840,9 +840,9 @@ for the lit colour. Category `Displays & Outputs` even though the neighbours in
- **NeoPixel panel 8x8** as its own class, reusing `drawAddressableLed` and the ring's pad handling.
Worth doing only after §6.1, whose helper it depends on. **~0.5 day.**
### 6.8 On-screen identification — the glass prints its own spec
### 6.8 On-screen identification — the glass prints its own spec — **done**
**Decided, not yet built.** The four displays that have a lit area — `OLEDDisplay`, `TFTDisplay`,
**Done.** The four displays that have a lit area — `OLEDDisplay`, `TFTDisplay`,
`CharacterLCD`, `Nokia5110LCD` — draw their variant string inside it, in the panel's own lit colour,
on by default and switchable off per component. This amends D4 for text that *identifies the part*
and leaves D4's ban on invented content standing.
@@ -908,9 +908,9 @@ measure, in pixels:
| 16x2 LCD | 508 × 114 |
| 20x4 LCD | 606 × 198 |
The tightest is the 0.91" OLED, and two lines of the 11 px `SILK_FONT` need 22 px of its 44. So the
drop-rather-than-squeeze rule holds here as it does for silkscreen, but nothing in the package
currently triggers it. The round GC9A01 is the one case where width is not the board's width: text
The tightest is the 0.91" OLED: two lines of the 11 px `SILK_FONT` need 28 px of its 44, measured
rather than estimated. So the drop-rather-than-squeeze rule holds here as it does for silkscreen,
but nothing in the package triggers it — every variant was rendered and looked at. The round GC9A01 is the one case where width is not the board's width: text
must fit the inscribed square of the 256 px disc, not its diameter.
**Ink colour is each class's to pass.** The OLED's `PIXEL_BLUE` on its near-black active area, white
@@ -940,6 +940,36 @@ before it.
**Effort.** ~0.5 day for the helper, the property on four classes and the tests. ~0.5 day more for
the character-grid refinement, which is independent and can follow.
**What the build settled.** Four things were decided at the keyboard rather than here:
1. *The line break is the comma, not the width.* The variant string reads "size, interface", so
breaking it there gives one property per line. Left to wrap on width alone every current variant
came out as a single line filling 95% of the glass — the 0.96" OLED's description is 153 px
against 162 px of usable panel — which is legible but reads as a line of text crammed into a
window rather than a readout.
2. *The width check happens after wrapping, not before.* `StringUtils.wrap` cannot break a single
word wider than the limit and hands one back oversized, so the helper measures each wrapped line
and bails if any still overflows. Without it the drop rule would have had a hole in exactly the
case it exists for.
3. *The Character LCD's ink is chosen by luminance,* through `CalcUtils.calculateLuminance` on the
screen colour. Both inks are real parts — the blue-backlit module shows light characters, the
yellow-green one dark — so this is faithful rather than merely defensive, and it is what stops a
pale backlight from being printed on in white.
4. *`Nokia5110LCD` gained the `getVariantLabel()` this section asked for,* returning
`84x48 PCD8544`, which also fills the BOM value column §11 item 1 left empty on it.
The character-grid refinement is **not** done; it remains the open item above.
**Tests.** `ScreenTextTest` covers the helper's own contract away from any component — that a panel
with room prints, that one too narrow or too shallow prints nothing at all, that `NONE` and an
empty variant leave the glass dark, and that the three printing settings differ. Each of the four
display tests then renders its panels twice, once printing and once with `Display.NONE`, and fails
if the two renderings match; rendering is what makes it a check on the drawing rather than on the
getter, since a screen that silently found no room would answer the getter either way. The shared
`MakerBoardTestSupport.renderPixels` added for this is reusable by anything else that needs to
catch a drawing change that moves no geometry. `CharacterLCDTest` is new — the class had no test at
all — and also pins down the two inks.
### 6.9 Deliberately not in this slice
E-paper (1.54" / 2.9" SSD1680) — growing but still niche, and the flexible-cable mounting is real