Making a browser in JavaFX: Lessons learned

Mon, Jan 15, 2024 7-minute read

The project for this week was to build our own custom browser in JavaFX.

Task:

Create a browser using JavaFX WebView. Start 10am Monday morning and hand it in by 4pm Thursday.

  • Navigation
    • text for URL and button to load websites
    • Navigation buttons (e.g. back, forward, reload)
    • Allow flexible user interaction (e.g. typing both “www.google.com” and “http://www.google.com” works)
  • Advanced website functionalities
    • Menu (e.g. history, bookmarks, print, HTML source)
    • Settings (e.g. setting home screen, colour theme, zoom)
    • Tabbed user interface

Screenshots

Browser homepage

Bookmarks page

Settings page

New things I learnt

  • JavaFX TableView - pretty painful to build all the columns
  • A bit more about Maven and dependencies - using from inside IntelliJ IDEA
  • JavaFX TabPanes - programatically opening and loading content into them
  • JavaFX WebView and WebEngine
  • Java Properties for storing my user settings in a text file
  • I used a Trello kanban board to keep track of things
    • I used the column headers: ToDo, In Progress, Bugs, Done but maybe using Not Started and Blocked columns might be better to have next time. There were so many features I thought I could add at the start, like Incognito and a custom app icon that just never got off the ground. Maybe a Wishlist column for things like that
  • I kept everything in Git but there was so much happening in the set up that it was hard to
  • Keep the start()/main() class small and try to move things into new method if not new classes - while balancing the overhead of working in different files.
  • Also got a good reminder to (should have known) not split code up too soon - moving bookmark functionality into a new class made it hard to call the parent’s openNewTab() method - I ended up passing a reference to the method into the new class.
    • I used the Consumer<T> functional interface to represent the method signature.
    • When calling the renderBookmarks method, pass the method reference.
public void openNewBrowserTabWithURL(String url) {
    // Logic to open a new browser tab with the given URL
}

// When calling the renderBookmarks method:
newTab.setContent(BookmarkManager.renderBookmarks(this::openNewBrowserTabWithURL));

and then in the BookmarkManager class:

public static VBox renderBookmarks(Consumer<String> openNewBrowserTabWithURL) {
    // ... other code ...

    // Use the passed method:
    openNewBrowserTabWithURL.accept(rowData.getUrl());

    // ... other code ...
}
  • Scene Builder and Controllers were a bit of a disaster. I love the idea of having separate view files and controllers but passing messages around from one view to another and keeping track of everything with a controller/multiple controllers was really difficult and I ended up scraping it all at the end of Tuesday. Two days of slow and frustrating work thrown away.

Problems I ran into

  • JavaFX stylesheets didn’t seem to be applied properly or not to the things I wanted them to. Apart from the strange syntax and keys that JavaFX wants like -fx-text-fill: white; which should turn the text white

Mistakes I made

  • I jumped straight into a modular design without realising how complex it is to load controllers and work out where the logic is
    • I should start with everything in the same file so I can get some momentum and then slowly extract things after that
  • Jumping into FXML and Scene Builder without fully understanding how the Controllers and @FXML injection worked

Things I want to check out more in the future

  • Observable lists
  • Hav a play with DirectoryChooser, Drag and Drop, FlowPane, SplitPane, TitledPane, TilePane, TreeView, TreeTableView, the charts, Animation, Canvas, Concurrency, Toolbar, Tooltip, ProgressBar
  • Try to use design patterns a bit more. I think I could have used the Observer Pattern to keep an eye on the current tab and change the window title

Thoughts

  • There’s not a huge amount of quality resources on this stuff - mainly the Oracle JavaFX docs which are a bit of a slog.
  • JavaFX is cool though and lets you get a lot done quickly. Once I start getting the hang of using controller files and FXML views, I will be flying with it.
  • Setting a MacOS app icon was really hard to find. I ended up going down a route that involved installing awt and all kinds of things to my dependencies and I still couldn’t get it to work in the time.
  • I had a custom BrowserPane inside and custom BrowserTab class and the nesting got a bit complicated. I eventually had to draw it out on paper but I should have done that sooner.
  • I’m still struggling with all the IntelliJ shortcuts. I’m so used to the things I can do in VSCode and my VIM navigation shortcuts save a lot of time but it’s still slow for me.
  • Getting up and running with a whole new GUI toolkit was a challenge. I didn’t even know different elements existed until towards the end. I’d like to have a way of surveying the lay of the land and at least learning what everything is.
  • Didn’t get a chance to use Obervables this time but will have a play for the next project.

Snippets

One-liner and utility method to make a 16x16px icon on a button

// Usage
backBtn.setGraphic(Utility.createIconButton("/icons/back_icon_64.png", 16, 16));

/**
 * Utility method for adding an image button */
 public static ImageView createIconButton(String iconPath, double width, double height) {
    Image image = new Image(Objects.requireNonNull(Utility.class.getResourceAsStream(iconPath)));
    ImageView imageView = new ImageView(image);
    imageView.setFitWidth(width);
    imageView.setFitHeight(height);
    return imageView;
}

Make a table row clickable

/*
Makes the row clickable by setting a RowFactory on the table and adding a listener to each row with a lambda to listen for the double click and open a new tab
*/
tableView.setRowFactory(tv -> {
	TableRow<Bookmark> row = new TableRow<>();
	row.setOnMouseClicked(event -> {
		if (event.getClickCount() == 2 && (!row.isEmpty())) {
			Bookmark rowData = row.getItem();
			System.out.println(rowData.getUrl());
			openNewBrowserTabWithURL.accept(rowData.getUrl());

		}
	});
	return row;
})

New Observable List

static ObservableList<Bookmark> bookmarks = FXCollections.observableArrayList();

Pretty-print a timestamp

/**
 * Prints the timestamp in a nice format for the bookmarks table
 *
 * @return String The timestamp in the format dd/MM/yyyy HH:mm
 */
 public String getTimestampString() {
    DateTimeFormatter dtf = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
    return dtf.format(timestamp);
}

Operations on a WebView

WebView webview = new WebView();
WebEngine webEngine = webview.getEngine();


public void refresh() {
    webEngine.reload();
}

public void zoomIn() {
    webview.setZoom(webview.getZoom() + 0.1);
}

public void zoomOut() {
    webview.setZoom(webview.getZoom() - 0.1);
}

public void resetZoom() {
    webview.setZoom(1.0);
}

Basic URL handling

(WebView needs a full URL like http://google.com to work - google.com is not enough)

        urlField.setOnAction(e -> {

            String urlString = urlField.getText();

           if (!urlString.contains(".") || urlString.contains(" ")) {
                urlString = searchURL + urlString;
            }

            if (urlString.startsWith("http://") || urlString.startsWith("https://")) {
                // don't need to add anything
            } else {
                urlField.setText("https://" + urlString);
            }

            webEngine.load(urlString);
        });

Save Properties

    public static void saveSettings(String homepage, String searchEngine, boolean showHomeIcon) {
        Properties properties = new Properties();
        properties.setProperty("homepage", homepage);
        properties.setProperty("searchEngine", searchEngine);
        properties.setProperty("showHomeIcon", String.valueOf(showHomeIcon));

        try (FileOutputStream out = new FileOutputStream(filePath)) {
            properties.store(out, "Browser Settings");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

Load those properties


public static Properties loadSettings(String filePath) {
    Properties properties = new Properties();
    try (FileInputStream in = new FileInputStream(filePath)) {
        properties.load(in);
    } catch (IOException e) {
        e.printStackTrace();
    }
    return properties;
}

Get one property

public static String getSearchEngine() {
    Properties properties = loadSettings(filePath);
    return properties.getProperty("searchEngine");
}

Save properties from a form

Button saveBtn = new Button("Save");
saveBtn.setOnAction(e -> {
    Settings.saveSettings(
            homePageTextField.getText(),
            searchEngineComboBox.getValue(),
            showHomePageCheckBox.isSelected()
    );
});

Open a new browser tab second from the end

/**
 * Open a new tab and select it */
 * public void openNewBrowserTab() {
    Tab newTab = new BrowserTab(null);
    tabPane.getTabs().add(tabPane.getTabs().size() - 1, newTab);
    tabPane.getSelectionModel().select(newTab);
}

Keyboard shortcuts

KeyCombination cmdW = new KeyCodeCombination(KeyCode.W, KeyCombination.SHORTCUT_DOWN);

// Close the current tab
scene.getAccelerators().put(cmdW, () -> {
    tabPane.getTabs().remove(tabPane.getSelectionModel().getSelectedItem());
});
MenuBar menuBar = new MenuBar();

Menu appMenu = new Menu("BRWZR");
MenuItem about = new MenuItem("About");
MenuItem settings = new MenuItem("Settings      ⌘ ,");

appMenu.getItems().addAll(about, new SeparatorMenuItem(), settings);

Menu fileMenu = new Menu("File");
MenuItem newTab = new MenuItem("New Tab        ⌘ T");
MenuItem closeTab = new MenuItem("Close Tab      ⌘ W");
MenuItem exit = new MenuItem("Exit");

newTab.setOnAction(e -> {
    this.openNewBrowserTab();
});
closeTab.setOnAction(e -> {
    tabPane.getTabs().remove(tabPane.getSelectionModel().getSelectedItem());
});
exit.setOnAction(e -> {
    System.exit(0);
});

fileMenu.getItems().addAll(newTab, closeTab, new SeparatorMenuItem(), exit);

menuBar.getMenus().addAll(appMenu, fileMenu);

Set Style and Set ID

hbox.setStyle("-fx-background-color: #f00000;");

appMenu.setId("appMenu");

Add a CSS file to the scene

scene = new Scene(borderPane, 800, 600);
String css = this.getClass().getResource("/styles.css").toExternalForm();
scene.getStylesheets().add(css);

Make a new tab button that can’t be closed

Tab addTab = new Tab("+");
addTab.setClosable(false);
tabPane.getTabs().add(addTab);

// Listener to detect when the "+" tab is clicked
tabPane.getSelectionModel().selectedItemProperty().addListener((observable, oldTab, newTab) -> {
    if (newTab == addTab) {
        this.openNewBrowserTab();
    }
});