From: Niki Roo Date: Tue, 22 Dec 2020 13:32:34 +0000 (+0100) Subject: Merge with master X-Git-Url: https://git.nikiroo.be/?a=commitdiff_plain;h=d831b327b7f7e21708241d8f511e5533b0a92407;p=nikiroo-utils.git Merge with master --- 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 0000000..3ea2622 Binary files /dev/null and b/ui/clear-16x16.png differ diff --git a/ui/search-16x16.png b/ui/search-16x16.png new file mode 100644 index 0000000..0164b31 Binary files /dev/null and b/ui/search-16x16.png differ