Blog-Archiv

Freitag, 31. Oktober 2025

Auto-Scaling Java Swing Image Viewer

Java ImageIO can render JPEG, GIF, PNG, BMP and WBMP image formats. It was introduced long ago in Java 1.4 (Sun times).

Ever wanted to show an image in Java/Swing in a user-friendly way? Here is a 110 lines image-viewer that sizes images proportionally to the window size while keeping the image's aspect ratio. It also resizes the image when the user resizes its container-window.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
import java.awt.*;
import java.awt.event.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import javax.imageio.ImageIO;
import javax.swing.*;

public class ImageViewer extends JPanel
{
    private final JTabbedPane tabbedPane;
    private List<BufferedImage> images = new ArrayList<>();
    
    public ImageViewer()  {
        super(new BorderLayout());
        
        add(this.tabbedPane = new JTabbedPane());
        
        // on every frame resize, resize focused image to fit frame
        addComponentListener(new ComponentAdapter() {
            // we need a deferred resize, because component-events come very frequently
            private final ActionListener imageResizer = new ActionListener() {
                @Override
                public void actionPerformed(ActionEvent e) {
                    final int selectedIndex = tabbedPane.getSelectedIndex();
                    if (images.size() > selectedIndex && images.get(selectedIndex) != null) {
                        final BufferedImage image = images.get(selectedIndex);
                        final JComponent selectedTab = (JComponent) tabbedPane.getSelectedComponent();
                        setImageOnTab(selectedIndex, selectedTab, image);
                    }
                }
            };
            
            private final Timer timer = new Timer(500, imageResizer);
            
            {
                timer.setRepeats(false); // default is true
            }
            
            @Override
            public void componentResized(ComponentEvent event) {
                final int selectedIndex = tabbedPane.getSelectedIndex();
                if (selectedIndex >= 0 && images.size() > selectedIndex && images.get(selectedIndex) != null)
                    if (timer.isRunning())
                        timer.restart();
                    else
                        timer.start();
            }
        }); // end ComponentAdapter
    }
    
    /** Replaces all currently shown images by the new ones. */
    public void showImages(File [] files)  {
        tabbedPane.removeAll(); // remove all former images
        images.clear();
        
        for (int i = 0; i < files.length; i++)  {
            final int index = i;
            tabbedPane.addTab(files[index].getName(), new JPanel());
            
            SwingUtilities.invokeLater(() -> { // show image later when tabbedPane has a size
                final JComponent tab = (JComponent) tabbedPane.getComponentAt(index);
                try {
                    final BufferedImage image = ImageIO.read(files[index]);
                    if (image == null)
                        throw new IllegalArgumentException("Can not read "+files[index]);
                    
                    images.add(index, image);
                    
                    setImageOnTab(index, tab, image);
                }
                catch (Exception e) {
                    if (images.size() <= index)
                        images.add(index, null); // occupy index
                    
                    final JLabel error = new JLabel(e.getMessage());
                    tabbedPane.setComponentAt(index, error);
                }
            });
        }
    }

    private void setImageOnTab(int index, JComponent tab, BufferedImage image) {
        final Dimension canvasDimension = new Dimension(tab.getWidth(), tab.getHeight());
        if (canvasDimension.width > 0 && canvasDimension.height > 0) {
            final Dimension imageDimension = new Dimension(image.getWidth(), image.getHeight());
            final Dimension scaled = calculateProportionalScale(imageDimension, canvasDimension);
            final ImageIcon icon = new ImageIcon((scaled != null)
                    ? image.getScaledInstance(scaled.width, scaled.height, Image.SCALE_DEFAULT)
                    : image);
            tabbedPane.setComponentAt(index, new JLabel(icon));
        }
        // else: resize event will render the image
    }

    private Dimension calculateProportionalScale(Dimension image, Dimension canvas) {
        if (canvas.width >= image.width && canvas.height >= image.height)
            return null; // canvas is bigger than image, nothing to do
        
        final double widthRatio  = (double) canvas.width  / (double) image.width;
        final double heightRatio = (double) canvas.height / (double) image.height;
        final double smallerRatio = Math.min(widthRatio, heightRatio);
        
        final int width = (int) Math.round(smallerRatio * (double) image.width);
        final int height = (int) Math.round(smallerRatio * (double) image.height);
        
        return new Dimension(width, height);
    }
}

To have this functionality as reusable as possible, the class extends JPanel (a Swing-component), not JFrame (which is a window). On line 12 we have a nested tabbed pane that will show each loaded image on its own tab. Line 13 allocates a list for all images to load in future, in order of the tabs on which they will be shown.

The constructor on line 15 starts with adding the tabbed pane which has no tabs yet, because no image files were given. It then creates a component-listener on line 21 that listens for resize events of the tabbed pane. This is a little longer than expected, because Swing sends lots of resize events, and scaling the image on each of these events would make no sense. On line 23 we have an ActionListener that performs the resize. The timer on line 35 will call this resizer. On line 37 there is an object-instance-initializer that once sets the timer to NOT repeat its action every 500 milliseconds. Finally on line 42 there is the resize receiver. It prolongs the timer when it has been started already, else it starts it. That means, as long as there are tight resize events coming in intervals below 500 millis, nothing will happen. But when a longer break happens the image will be resized.

What happens on resize is implemented starting from line 25. When a tab was selected and an image is available for it, it calls the setImageOnTab() method. Before we go to that, let's look at the showImages() method on ine 54.

The showImages() can be called multiple times with different image files. It will always dismiss the currently shown images and create new image tabs. Thus it first clears its object-state on line 55 and 56. Then it loops the given files and creates a tab for each one. It does not yet render the image on the created tab, because there might be situations when the tab has no size yet, and then the image would not know how big or small it should become. So on line 62 a deferred image rendering has been implemented through invokeLater(). The showImages() method finishes after starting all these functions to be invoked.

What happens now is that every started function will be executed one by one in the EventQueue, without concurrency. On line 63, the indexed tab is retrieved, then the image file is loaded and added to the images list on line 69. We need this image-cache because on window-resize the image will be scaled again. Finally the setImageOnTab() method is called on line 71, this is the same as we had in the resize-listener on line 30.

The setImageOnTab() method on line 84 doesn't do anything when the given tab (canvas) has no size yet. For sure there will come a resize-event afterwards and the method will be called again from line 30. But when a size of the tab is present, it scales the image to it and then puts a label with the resulting ImageIcon on it. In case the canvas (tab) is bigger than the image, no scaling is needed, this will be fast then.

Whether scaling is needed is decided in calculateProportionalScale() on line 97. If the canvas (tab) is smaller than the image (the normal case for photos), both width- and height-ratios are calculated. The smaller of them will be sufficient to render the image so that it fits perfectly into the tab. On line 105 and 106 the scaled dimension is then calculated and returned on line 108.


Here is main() procedure so that you can test the class. Image files must be given as commandline arguments, like

java -cp . ImageViewer image1.jpeg image2.gif
    /** Test main. */
    public static void main(String[] args) {
        if (args.length <= 0) {
            System.out.println("SYNTAX:java ImageViewer imageFile [imageFile ...]");
        }
        else {
            final File[] files = new File[args.length];
            for (int i = 0; i < args.length; i++)
                files[i] = new File(args[i]);

            final ImageViewer imageViewer = new ImageViewer();
            
            final JFrame frame = new JFrame("Image Viewer"); // title bar text
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.getContentPane().add(imageViewer);
            frame.setSize(new Dimension(400, 400));
            frame.setLocationByPlatform(true);
            frame.setVisible(true);
            
            imageViewer.showImages(files);
        }
    }


That's it, no more. Hope this was helpful for someone, be it due to the scaling algorithm, be it due to Swing techniques!




Samstag, 25. Oktober 2025

Do-Not-Ask-Anymore Dialog in Java Swing

There are situations where a dialog can be really nasty. For example, in a critical processing situation the application needs to ask the user for a decision, let's say about the deletion of some data. A normal modal dialog would do that sufficiently, but not when the decision is called from a loop, meaning the dialog appears over and over again, always asking the same question.

There may be a few situations where such repeats are actually necessary, but in general the user would like to answer the question just once for all elements in the loop. A popular example is when copying a backup-directory back to its origin, and the application asks for each there existing file whether it should overwrite it.

For that purpose the "Do-Not-Ask-Anymore Dialog" was invented. The Swing JOptionPane.showConfirmDialog() dialog method does not have such a functionality, but it offers means to implement such. In object-oriented thinking, remembering a decision can be solved easily by a class that contains (1) a field for the once "persisted" answer and (2) a method that either shows a dialog or delivers the answer silently when it was already made "persistent".

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import java.awt.*;
import java.util.Objects;
import javax.swing.*;

/**
 * A modal confirm-dialog that may not show but deliver a persistent
 * answer when the user once activated its "Remember" checkbox.
 */
public class DoNotAskAnymoreConfirmDialog
{
    private final String title;
    private final String message;
    private final String rememberAnswerText;
    
    private Boolean answer;
    
    public DoNotAskAnymoreConfirmDialog(String title, String message) {
        this(title, message, null);
    }
    public DoNotAskAnymoreConfirmDialog(String title, String message, String rememberAnswerText) {
        this.title = Objects.requireNonNull(title);
        this.message = Objects.requireNonNull(message);
        this.rememberAnswerText = (rememberAnswerText != null)
                ? rememberAnswerText
                : "Remember That and Don't Ask Anymore";
    }
    
    public boolean answer(Component parent) {
        if (answer != null) // rememberAnswer checkbox has been selected once
            return answer.booleanValue();
        
        final JLabel messageLabel = new JLabel(message);
        final JCheckBox rememberAnswer = new JCheckBox(rememberAnswerText, false);
        final JPanel panel = new JPanel(new BorderLayout());
        panel.add(messageLabel, BorderLayout.CENTER);
        panel.add(rememberAnswer, BorderLayout.SOUTH);
        
        final boolean dialogAnswer =
            (JOptionPane.YES_OPTION == JOptionPane.showConfirmDialog(
                parent, 
                panel, 
                title, 
                JOptionPane.YES_NO_OPTION, 
                JOptionPane.WARNING_MESSAGE)
            );
        
        if (rememberAnswer.isSelected())
            answer = Boolean.valueOf(dialogAnswer);
        
        return dialogAnswer;
    }
}

The constructors on lines 17 and 20 require that for each decision-semantic there must be a new constructor call. That ensures that a dialog-object is not reused for a different decision and may silently answer the wrong question. The title and message parameters are required (not allowed to be null) through Objects.requireNonNull(), the rememberAnswerText is optional because the text on the checkbox will be always the same. The instance-fields for the constructor parameters are final, so that the semantic never could be changed.

The answer() method on line 28 first looks at the answer field on line 15 and returns its value when not null. It being null means the user did not yet activate the "Remember" checkbox. In that case the dialog is shown on screen and the user can make a decision, optionally activating the checkbox (line 47) to avoid repeated dialogs.

The answer field MUST NOT be static, because then all dialog-semantics would use the same answer! (This beautifully proves how valueable object-oriented thinking is.)

For sure it may be necessary to provide more answers than just TRUE and FALSE. When you copy a backup-directory back to its origin, you may want to ask whether to (1) overwrite or (2) merge all folders below that directory. Then a Boolean answer may be not enough. Feel free to abstract this class even more!




Samstag, 4. Oktober 2025

Two-Factor Authentication on LINUX with KeePassXC

I had to set up a "2FA" (Two Factor Authentication) or "TOTP" (Timedbased One-Time Password) tool for my github account. I use github on my LINUX 6.8.0-85 / Ubuntu 24.04.3 desktop computer from my web-browser and from my Eclipse IDE. Github now forces their users to use 2FA, with a delay of 45 days. They do not recommend any tool, so I had to search for a LINUX app that provides 2FA (TOTP).

First I installed gnome-authenticator 3.32.2, but this did not work. It could not scan the github QR-code and it gave me no way to manually enter the "setup key" that github offers as alternative for scanning. Error message was "AccessDenied ... SelectArea is not allowed" (I love these messages without fix hint:-). I searched for solutions and found I had to install gstreamer1.0-gtk4, but this did not work, I found no PPA repository for it, moreover I had an installed version gstreamer1.0-gtk3. As it is useless, I removed gnome-authenticator from my system again.

Next I found an "askubuntu" page that recommeded KeePassXC, and this installation worked fine via commandline "sudo apt install keepassxc". The graphical user-interface can be launched afterwards via "keepassxc".

Here are some screenshots of what I did then in that tool. I must say that I am completely new to 2FA and do not know at all how this works and what you have to do for it. I just followed the instructions of the "askubuntu" page:

  • Create a new KeePassXC database - the GUI tool prompts you for parameters and does this for you, no problem
  • Create a new "Entry" with username and password, here I used my github username and password, it did not require any password standards, or maybe my password was good enough
  • Select the "Entry" and go to menu "Entries" - "TOTP" - "Set up TOTP", here I entered the "setup key" from github
  • In the context-menu of the selected "Entry" I chose "TOTP" - "Copy TOTP" and entered the result on the github page
  • github then labeled my 2FA authentication as "Configured"

Here are the screenshots of most things I have done:

Here I already created my user "Entry". The screenshot shows the context menu that leads to "New Entry".

Here is the UI where you create the "New Entry". You need to enter your username and password for that. I also entered the URL of my github project.

This is how the app looks when the "Entry" is created.

The menu to set up TOTP. Don't forget to select your "Entry" first.

Here I entered the "setup key" from github as "Secret Key".

Here is the context menu on the selected "Entry" to copy a generated TOTP.

This is how my github account "Password and authentication" page looked after I entered the generated TOTP in the github input field.

One more evening gone for LINUX! But I don't give up trusting in open-source software, it's simply the better concept.


UPDATE 2025-10-04: Today I logged out from github to explore how to log in via 2FA with keepassxc.

  • After confirming the dialog that I really want to log out, I clicked "Log in" on the github main page
  • Until now I used a generated password sequence for accessing github; in the github login dialog, I entered my username and that generated long password, but this didn't work, the password was reported to be invalid
  • So I tried the password I entered yesterday on my keepassxc application, and this worked (I can't say if keepassxc submitted that password to github or if that was my initial old github password, because I used the same for both)
  • After receiving the correct password, github asked me to "Enter the code from your two-factor authentication app ... below"
  • I launched keepassxc, selected my "Entry", used the context menu on it und chose "TOTP" - "Copy TOTP" (see screenshot above)
  • I guess the app copied the code to the system-clipboard, so that I could paste it into the input field ("XXXXXX") on the github page in my browser
  • I didn't even have to press ENTER or click the green "Verify" button, immediately my github account page opened.

So for performing 2FA with github, you always need your 2FA app open to copy a generated TOTP from it. For starting the 2FA app, you always have to enter your password in it. Thus 2FA authentication takes more time than a simple log-in with username and password, and it requires the presence of always the same 2FA app on your device. If you lose your device, you lose access to your github account, which only can be restored using the recovery codes that github provided you while setting up 2FA for your account. If you missed that, or stored the recovery codes on your now damaged device, the github support will not be able to give you back your account! That is why they recommend to store recovery codes in some Internet cloud.

I now tried to push some source-code changes in my Eclipse, and it worked. I had configured Eclipse to also use the github-generated long password sequence, so I wonder if that will break in 45 days, because obviously that sequence became invalid.


UPDATE 2025-11-03: My github page did not respond to clicks. When I reloaded it, it took a very long time (nearly a minute) to finish its progress-bar, but still did not render. It looked like it had stopped and didn't do anything anymore.

I opened a new browser-tab and entered https://github.com, there I was asked to renew my Two-Factor-Authentication, see screenshot in UPDATE above for how it looked. I tried to remember the 2FA-tool I used and found keepassxc. Launching it, I was asked to enter my local password for the keepassxc database. After entering it I was able to select my "Entry", open the context menu on it, click "TOTP" - "Copy TOTP", and paste it in the XXXX field on the github web-page. Success was displayed, but I had to completely close the old github browser-tab and open my account in a new browser-tab.