From d831b327b7f7e21708241d8f511e5533b0a92407 Mon Sep 17 00:00:00 2001 From: Niki Roo Date: Tue, 22 Dec 2020 14:32:34 +0100 Subject: [PATCH] Merge with master --- IOUtils.java | 28 +++ ui/Item.java | 479 ++++++++++++++++++++++++++++++++++++++++++++ ui/SearchBar.java | 148 ++++++++++++++ ui/UIUtils.java | 37 ++++ ui/clear-16x16.png | Bin 0 -> 1232 bytes ui/search-16x16.png | Bin 0 -> 1274 bytes 6 files changed, 692 insertions(+) create mode 100644 ui/Item.java create mode 100644 ui/SearchBar.java create mode 100644 ui/clear-16x16.png create mode 100644 ui/search-16x16.png diff --git a/IOUtils.java b/IOUtils.java index 3d252ea..439964d 100644 --- a/IOUtils.java +++ b/IOUtils.java @@ -403,6 +403,34 @@ public class IOUtils { return loader.getResourceAsStream(name); } + + /** + * Return the running directory/file, that is, the root binary directory for + * running java classes or the running JAR file for JAR files. + * + * @param clazz + * a Class from the running program (will only have an impact + * when not running from a JAR file) + * @param base + * return the base directory (the one where the binary root or + * the JAR file resides) + * + * @return the directory or file + */ + public static File getRunningDirectory( + @SuppressWarnings("rawtypes") Class clazz, boolean base) { + String uri = clazz.getProtectionDomain().getCodeSource().getLocation() + .toString(); + if (uri.startsWith("file:")) + uri = uri.substring("file:".length()); + File root = new File(uri); + + if (base) { + root = root.getParentFile(); + } + + return root; + } /** * Return a resetable {@link InputStream} from this stream, and reset it. diff --git a/ui/Item.java b/ui/Item.java new file mode 100644 index 0000000..c8afc7f --- /dev/null +++ b/ui/Item.java @@ -0,0 +1,479 @@ +package be.nikiroo.utils.ui; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.util.HashMap; +import java.util.Map; + +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; + +/** + * A graphical item that can be presented in a list and supports user + * interaction. + *

+ * Can be selected, hovered... + * + * @author niki + */ +abstract public class Item extends JPanel { + static private final long serialVersionUID = 1L; + + static private Map empty = new HashMap(); + static private Map error = new HashMap(); + static private Map statuses = new HashMap(); + + private String id; + private boolean selected; + private boolean hovered; + + private String mainTemplate; + private String secondaryTemplate; + + private boolean hasImage; + private JLabel title; + private JLabel secondary; + private JLabel statusIndicatorOn; + private JLabel statusIndicatorOff; + private JLabel statusIndicatorUnknown; + private Image image; + private boolean imageError; + + private String cachedMain; + private String cachedOptSecondary; + private Integer cachedStatus; + + /** + * Create a new {@link Item} + * + * @param id + * an ID that represents this {@link Item} (can be NULL) + * @param hasImage + * this {@link Item} will contain an image + */ + public Item(String id, boolean hasImage) { + this.id = id; + this.hasImage = hasImage; + init(hasImage); + } + + // Configuration : + + protected int getMaxDisplaySize() { + return 40; + } + + protected int getCoverWidth() { + return 100; + } + + protected int getCoverHeight() { + return 150; + } + + protected int getTextWidth() { + return getCoverWidth() + 40; + } + + protected int getTextHeight() { + return 50; + } + + protected int getCoverVOffset() { + return 20; + } + + protected int getCoverHOffset() { + return 0; + } + + protected int getHGap() { + return 10; + } + + /** Colour used for the secondary item (author/word count). */ + protected Color getSecondaryColor() { + return new Color(128, 128, 128); + } + + /** + * Return a display-ready version of the main information to show. + *

+ * Note that you can make use of {@link Item#limit(String)}. + * + * @return the main info in a ready-to-display version, cannot be NULL + */ + abstract protected String getMainInfoDisplay(); + + /** + * Return a display-ready version of the secondary information to show. + *

+ * Note that you can make use of {@link Item#limit(String)}. + * + * @return the main info in a ready-to-display version, cannot be NULL + */ + abstract protected String getSecondaryInfoDisplay(); + + /** + * The current status for the status indicator. + *

+ * Note that NULL and negative values will create "hollow" indicators, while + * other values will create "filled" indicators. + * + * @return the status which can be NULL, presumably for "Unknown" + */ + abstract protected Integer getStatus(); + + /** + * Get the background colour to use according to the given state. + *

+ * Since it is an overlay, an opaque colour will of course mask everything. + * + * @param enabled + * the item is enabled + * @param selected + * the item is selected + * @param hovered + * the mouse cursor currently hovers over the item + * + * @return the correct background colour to use + */ + abstract protected Color getOverlayColor(boolean enabled, boolean selected, + boolean hovered); + + /** + * Get the colour to use for the status indicator. + *

+ * Return NULL if you don't want a status indicator for this state. + * + * @param status + * the current status as returned by {@link Item#getStatus()} + * + * @return the base colour to use, or NULL for no status indicator + */ + abstract protected Color getStatusIndicatorColor(Integer status); + + /** + * Initialise this {@link Item}. + */ + private void init(boolean hasImage) { + if (!hasImage) { + title = new JLabel(); + mainTemplate = "${MAIN}"; + secondary = new JLabel(); + secondaryTemplate = "${SECONDARY}"; + secondary.setForeground(getSecondaryColor()); + + JPanel idTitle = null; + if (id != null && !id.isEmpty()) { + JLabel idLabel = new JLabel(id); + idLabel.setPreferredSize(new JLabel(" 999 ").getPreferredSize()); + idLabel.setForeground(Color.gray); + idLabel.setHorizontalAlignment(SwingConstants.CENTER); + + idTitle = new JPanel(new BorderLayout()); + idTitle.setOpaque(false); + idTitle.add(idLabel, BorderLayout.WEST); + idTitle.add(title, BorderLayout.CENTER); + } + + setLayout(new BorderLayout()); + if (idTitle != null) + add(idTitle, BorderLayout.CENTER); + add(secondary, BorderLayout.EAST); + } else { + image = null; + title = new JLabel(); + secondary = new JLabel(); + secondaryTemplate = ""; + + String color = String.format("#%X%X%X", getSecondaryColor() + .getRed(), getSecondaryColor().getGreen(), + getSecondaryColor().getBlue()); + mainTemplate = String + .format("" + + "" + + "${MAIN}" + "
" + "" + + "${SECONDARY}" + "" + "" + + "", getTextWidth(), getTextHeight(), color); + + int ww = Math.max(getCoverWidth(), getTextWidth()); + int hh = getCoverHeight() + getCoverVOffset() + getHGap() + + getTextHeight(); + + JPanel placeholder = new JPanel(); + placeholder + .setPreferredSize(new Dimension(ww, hh - getTextHeight())); + placeholder.setOpaque(false); + + JPanel titlePanel = new JPanel(new BorderLayout()); + titlePanel.setOpaque(false); + titlePanel.add(title, BorderLayout.NORTH); + + titlePanel.setBorder(BorderFactory.createEmptyBorder()); + + setLayout(new BorderLayout()); + add(placeholder, BorderLayout.NORTH); + add(titlePanel, BorderLayout.CENTER); + } + + // Cached values are NULL, so it will be updated + updateData(); + } + + /** + * The book current selection state. + * + * @return the selection state + */ + public boolean isSelected() { + return selected; + } + + /** + * The book current selection state, + * + * @param selected + * TRUE if it is selected + */ + public void setSelected(boolean selected) { + if (this.selected != selected) { + this.selected = selected; + repaint(); + } + } + + /** + * The item mouse-hover state. + * + * @return TRUE if it is mouse-hovered + */ + public boolean isHovered() { + return this.hovered; + } + + /** + * The item mouse-hover state. + * + * @param hovered + * TRUE if it is mouse-hovered + */ + public void setHovered(boolean hovered) { + if (this.hovered != hovered) { + this.hovered = hovered; + repaint(); + } + } + + /** + * Update the title, paint the item. + */ + @Override + public void paint(Graphics g) { + Rectangle clip = g.getClipBounds(); + if (clip == null || clip.getWidth() <= 0 || clip.getHeight() <= 0) { + return; + } + + updateData(); + + super.paint(g); + if (hasImage) { + Image img = image == null ? getBlank(false) : image; + if (isImageError()) + img = getBlank(true); + + int xOff = getCoverHOffset() + (getWidth() - getCoverWidth()) / 2; + g.drawImage(img, xOff, getCoverVOffset(), null); + + Integer status = getStatus(); + boolean filled = status != null && status > 0; + Color indicatorColor = getStatusIndicatorColor(status); + if (indicatorColor != null) { + UIUtils.drawEllipse3D(g, indicatorColor, getCoverWidth() + xOff + + 10, 10, 20, 20, filled); + } + } + + Color bg = getOverlayColor(isEnabled(), isSelected(), isHovered()); + g.setColor(bg); + g.fillRect(clip.x, clip.y, clip.width, clip.height); + } + + /** + * The image to display on image {@link Item} (NULL for non-image + * {@link Item}s). + * + * @return the image or NULL for the empty image or for non image + * {@link Item}s + */ + public Image getImage() { + return hasImage ? image : null; + } + + /** + * Change the image to display (does not work for non-image {@link Item}s). + *

+ * NULL is allowed, an empty image will then be shown. + * + * @param image + * the new {@link Image} or NULL + * + */ + public void setImage(Image image) { + this.image = hasImage ? image : null; + } + + /** + * Use the ERROR image instead of the real one or the empty one. + * + * @return TRUE if we force use the error image + */ + public boolean isImageError() { + return imageError; + } + + /** + * Use the ERROR image instead of the real one or the empty one. + * + * @param imageError + * TRUE to force use the error image + */ + public void setImageError(boolean imageError) { + this.imageError = imageError; + } + + /** + * Make the given {@link String} display-ready (i.e., shorten it if it is + * too long). + * + * @param value + * the full value + * + * @return the display-ready value + */ + protected String limit(String value) { + if (value == null) + value = ""; + + if (value.length() > getMaxDisplaySize()) { + value = value.substring(0, getMaxDisplaySize() - 3) + "..."; + } + + return value; + } + + /** + * Update the title with the currently registered information. + */ + private void updateData() { + String main = getMainInfoDisplay(); + String optSecondary = getSecondaryInfoDisplay(); + Integer status = getStatus(); + + // Cached values can be NULL the first time + if (!main.equals(cachedMain) + || !optSecondary.equals(cachedOptSecondary) + || status != cachedStatus) { + title.setText(mainTemplate // + .replace("${MAIN}", main) // + .replace("${SECONDARY}", optSecondary) // + ); + secondary.setText(secondaryTemplate// + .replace("${MAIN}", main) // + .replace("${SECONDARY}", optSecondary) // + + " "); + + Color bg = getOverlayColor(isEnabled(), isSelected(), isHovered()); + setBackground(bg); + + if (!hasImage) { + remove(statusIndicatorUnknown); + remove(statusIndicatorOn); + remove(statusIndicatorOff); + + Color k = getStatusIndicatorColor(getStatus()); + JComponent statusIndicator = statuses.get(k); + if (!statuses.containsKey(k)) { + statusIndicator = generateStatusIndicator(k); + statuses.put(k, statusIndicator); + } + + if (statusIndicator != null) + add(statusIndicator, BorderLayout.WEST); + } + + validate(); + } + + this.cachedMain = main; + this.cachedOptSecondary = optSecondary; + this.cachedStatus = status; + } + + /** + * Generate a status indicator for the given colour. + * + * @param color + * the colour to use + * + * @return a status indicator ready to be used + */ + private JLabel generateStatusIndicator(Color color) { + JLabel indicator = new JLabel(" ") { + private static final long serialVersionUID = 1L; + + @Override + public void paint(Graphics g) { + super.paint(g); + + if (color != null) { + Dimension sz = statusIndicatorOn.getSize(); + int s = Math.min(sz.width, sz.height); + int x = Math.max(0, (sz.width - sz.height) / 2); + int y = Math.max(0, (sz.height - sz.width) / 2); + + UIUtils.drawEllipse3D(g, color, x, y, s, s, true); + } + } + }; + + indicator.setBackground(color); + return indicator; + } + + private Image getBlank(boolean error) { + Dimension key = new Dimension(getCoverWidth(), getCoverHeight()); + Map images = error ? Item.error : Item.empty; + + BufferedImage blank = images.get(key); + if (blank == null) { + blank = new BufferedImage(getCoverWidth(), getCoverHeight(), + BufferedImage.TYPE_4BYTE_ABGR); + + Graphics2D g = blank.createGraphics(); + try { + g.setColor(Color.white); + g.fillRect(0, 0, getCoverWidth(), getCoverHeight()); + + g.setColor(error ? Color.red : Color.black); + g.drawLine(0, 0, getCoverWidth(), getCoverHeight()); + g.drawLine(getCoverWidth(), 0, 0, getCoverHeight()); + } finally { + g.dispose(); + } + images.put(key, blank); + } + + return blank; + } +} diff --git a/ui/SearchBar.java b/ui/SearchBar.java new file mode 100644 index 0000000..ffbc78b --- /dev/null +++ b/ui/SearchBar.java @@ -0,0 +1,148 @@ +package be.nikiroo.utils.ui; + +import java.awt.BorderLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.io.IOException; +import java.io.InputStream; + +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; + +import be.nikiroo.utils.IOUtils; + +/** + * A generic search/filter bar. + * + * @author niki + */ +public class SearchBar extends ListenerPanel { + static private final long serialVersionUID = 1L; + static private ImageIcon searchIcon; + static private ImageIcon clearIcon; + + private JButton search; + private JTextField text; + private JButton clear; + + private boolean realTime; + + /** + * Create a new {@link SearchBar}. + */ + public SearchBar() { + setLayout(new BorderLayout()); + + // TODO: make an option to change the default setting here: + // (can already be manually toggled by the user) + realTime = true; + + if (searchIcon == null) + searchIcon = getIcon("search-16x16.png"); + if (clearIcon == null) + clearIcon = getIcon("clear-16x16.png"); + + search = new JButton(searchIcon); + if (searchIcon == null) { + search.setText("[s]"); + } + UIUtils.setButtonPressed(search, realTime); + search.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + realTime = !realTime; + UIUtils.setButtonPressed(search, realTime); + text.requestFocus(); + + if (realTime) { + fireActionPerformed(getText()); + } + } + }); + + text = new JTextField(); + text.addKeyListener(new KeyAdapter() { + @Override + public void keyTyped(final KeyEvent e) { + super.keyTyped(e); + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + boolean empty = (text.getText().isEmpty()); + clear.setVisible(!empty); + + if (realTime) { + fireActionPerformed(getText()); + } + } + }); + } + }); + text.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (!realTime) { + fireActionPerformed(getText()); + } + } + }); + + clear = new JButton(clearIcon); + if (clearIcon == null) { + clear.setText("(c)"); + } + clear.setBackground(text.getBackground()); + clear.setVisible(false); + clear.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + text.setText(""); + clear.setVisible(false); + text.requestFocus(); + + fireActionPerformed(getText()); + } + }); + + add(search, BorderLayout.WEST); + add(text, BorderLayout.CENTER); + add(clear, BorderLayout.EAST); + } + + /** + * Return the current text displayed by this {@link SearchBar}, or an empty + * {@link String} if none. + * + * @return the text, cannot be NULL + */ + public String getText() { + // Should usually not be NULL, but not impossible + String text = this.text.getText(); + return text == null ? "" : text; + } + + @Override + public void setEnabled(boolean enabled) { + search.setEnabled(enabled); + clear.setEnabled(enabled); + text.setEnabled(enabled); + super.setEnabled(enabled); + } + + private ImageIcon getIcon(String name) { + InputStream in = IOUtils.openResource(SearchBar.class, name); + if (in != null) { + try { + return new ImageIcon(IOUtils.toByteArray(in)); + } catch (IOException e) { + e.printStackTrace(); + } + } + + return null; + } +} diff --git a/ui/UIUtils.java b/ui/UIUtils.java index e4eb000..ce7bcc1 100644 --- a/ui/UIUtils.java +++ b/ui/UIUtils.java @@ -12,6 +12,7 @@ import java.awt.RenderingHints; import java.io.IOException; import java.net.URISyntaxException; +import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JEditorPane; import javax.swing.JLabel; @@ -31,6 +32,9 @@ import be.nikiroo.utils.VersionCheck; * @author niki */ public class UIUtils { + static private Color buttonNormal; + static private Color buttonPressed; + /** * Set a fake "native Look & Feel" for the application if possible * (check for the one currently in use, then try GTK). @@ -331,4 +335,37 @@ public class UIUtils { return JOptionPane.showConfirmDialog(parentComponent, updateMessage, title, JOptionPane.DEFAULT_OPTION) == JOptionPane.OK_OPTION; } + + /** + * Set the given {@link JButton} as "pressed" (selected, but with more UI + * visibility). + *

+ * The {@link JButton} will answer {@link JButton#isSelected()} if it is + * pressed. + * + * @param button + * the button to select/press + * @param pressed + * the new "pressed" state + */ + static public void setButtonPressed(JButton button, boolean pressed) { + if (buttonNormal == null) { + JButton defButton = new JButton(" "); + buttonNormal = defButton.getBackground(); + if (buttonNormal.getBlue() >= 128) { + buttonPressed = new Color( // + Math.max(buttonNormal.getRed() - 100, 0), // + Math.max(buttonNormal.getGreen() - 100, 0), // + Math.max(buttonNormal.getBlue() - 100, 0)); + } else { + buttonPressed = new Color( // + Math.min(buttonNormal.getRed() + 100, 255), // + Math.min(buttonNormal.getGreen() + 100, 255), // + Math.min(buttonNormal.getBlue() + 100, 255)); + } + } + + button.setSelected(pressed); + button.setBackground(pressed ? buttonPressed : buttonNormal); + } } diff --git a/ui/clear-16x16.png b/ui/clear-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..3ea2622aa1ef1f9ad0b85322e5388be709d5c678 GIT binary patch literal 1232 zcmZuxc~H|w6y9(I0Vz0A4N){{?u3wEViF`Gas?743|Anc&Ts{dSfMl^n5t0_E9z+R z06FYbtL@kpYAXy-og%1XQQ}k-D4?J~O+f|$k*nr)?SGx=?!5Qy`}TV?Z+G7A(deiM zHy5f4LdZ?63X8?)@!l5MW6u@3$6;{T9;^vQ=-Nrwzf&A>OiNS6Y7pAXM#x-*&^)Hh zR)lg1ghrAPQj{aKJiDqXCIBJ3_0f^>N-Pj90Pz+Ao1btd7OnvJyaD-Z5R8Fn6nvlA zgoEJo6a+s#<39rW{*(MZ;64Fg<(QxkZ~^S!AR8d(&1C+_uox%yTLT~U1a%Dr^$bZ~ zO$z`dQ#Mi8b3w}k&ds}mC;j3{Ag00pwk5jmhIn{t1pqM%#Dgw+^*7=f-~x!O^P%-E z8|#}z{jWviAZ@xwKXKltd&C34cX%fBMtem0CHb`$_UTK${nIf3900*zAZr`sAFmcZ zot6Ov%me>9D6ahC6PqMx?H2*?pMrl$6-%4KYwD2#@H#+vvDr(jXMF!~-M=B{`T%Fg zK1R~6rCI~Kaaaw&zH9raqLool#=bMd{&kX?SLT_R#rU?Hbr%@5!z)uwtojj7CvaW> zvtfMsC#R`}SJ`dAY6V8)l=u0z)fX)EFCVh%tn7!tZU=VD4DDjas_OPtl@I7OLs-VT zWfS(;v;ca1djLD$z1sq?4Pe87jd{zKbg*!D(P85kr+KM*Dk1ie%Cq+!C_ZR56(3on zH*lB?Dw~sbwkkg_*I>*GNH@sD1V<{@9V$snO-)SNqK?xB1_p}#*Trp4(nLmvhJ?ta z(r}e3C@9F!&(DX?_lrsN_mfKqk$}sk(`b}cG`>dX!{hQ~{>$i0SLa0zWb#UtZY7oK z&0spZEx}D<@8Y&Je6u@^vxFriujV-{^LC=~yq-G^;k_fq*r@l-`2Qozynnwrfh^l+ zK5#H2-8!zfTWcBAk+d&Iw+2|Ov7`j+tASVBNEjy!cBF){ITLxqz+A4wh7K$Z?VNo( zx|I~(^R{Tu+$2>bqblZPIo=GsmWxVro3@mY+9Hqa{x!ghBz@c4@G@?fTX_td!` zCY+C|d+WzlQ7OqPO>bl7p1t8233-+aHy3iID3h)($4WMBR4VVc3HaY#&7|xc9T{6e zy+fhgW>r*dN$Mp6Tv6?#h~RMfsbYL)mQkrQrl%P7inJU(1|%UQgpiO43BPy=p^(WG zghW6{6a-OZFi8KC@NrhUA)^2%h_PBMB&6|Tf2>nTWCB8r7Zb{S{1qXjRz`)@gd`vO E2T8{XCIA2c literal 0 HcmV?d00001 diff --git a/ui/search-16x16.png b/ui/search-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..0164b31df14111b448b3b008d87a9cff5e708808 GIT binary patch literal 1274 zcmah}YfO`86n?QVKp--}LF<583Y1~BZ9qcfM6kqm0x3{LXc?E5ixRnLnV>OthRe7J z8a8BHB0(wPUYRl&&;b<~*CKalX`x&?AY2TIn{F99-G1%Y-sJn<_d7Y~d7kr}H(xG0 z;F8%<+);#(8H>pXg56yA7-8T|A|DHeO+Vh--y5M9MJ9hn8^AZ7&kXWM=#CRYqD+K7 zKuGi}LTMC)wxSTCmm*}HQt?yZIfRb*vHdu{kkH%P+uGWyR4QdM*%BW22*x55dqB5jQOd;*jZh`V5hu$%ste z+1UY1*CL5WQr7nN_Tu8=S+R_D|1so%c(;t~fXCG~Km#ZLZE$dK&{0@!msXvTki+ij_FzsZh^(xwZ|{-{ zMOiU%3E*B#L2*dhJ?{17mX;Qf4d#H`;<~!0&z?&p66kAaXlQI~Y;tn)@bEDAVYcpq zW(k7=ba(TAmw}_LxxPi4vZLe<&(rQ>J-2RfDVhe<-kw3X6{{PI|{@H?|x%Ii_b~ zg*m16fB_)An9G+Kvb=;sANVj%VTRp8$i!0j=%KO-8weT-S^mC;YCU}`gw=e&zJVG8 z7Q>rkGsTaB#R&`vo{NeRVdDJBH*6z5zlXW#!2d)d%DtAzy_@rWM^?>oSEgueQT#(g z=d0Qu<+Fvh#GdHA39o3)^Pyldot}E+J#YMU^ORU#>Y4cNQ;*w8Rj4ROtIRmCKE->P zUiif03|k~0ygfAeex#(O^wp%?UO>8kVYz-LakiQ3mG~7~g4h?>B%)9@;9stww`FFT zUB37pq;{Shx0Dh`36W;kmq^&j#67z6=Zti3sL8%d6PxDx@@Z&W#<{HG z=Ici3DD=H56hsSS>HM3qupt_SMj=z&$rKL`jY4