diff --git a/pom.xml b/pom.xml
index bdf66c9..8e14f19 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,6 +16,7 @@
+
org.openjfx
javafx-maven-plugin
@@ -24,6 +25,34 @@
com.rdesk.Main
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.3
+
+
+
+ package
+
+ shade
+
+
+
+
+ false
+
+
+
+ com.rdesk.Main
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/rdesk/Main.java b/src/main/java/com/rdesk/Main.java
index 513f8ea..cd16d7d 100644
--- a/src/main/java/com/rdesk/Main.java
+++ b/src/main/java/com/rdesk/Main.java
@@ -1,5 +1,6 @@
package com.rdesk;
+import com.rdesk.applications.entertainment.comic.Xkcd;
import com.rdesk.applications.productivity.Calculator;
import com.rdesk.applications.productivity.browsing.TabbedBrowser;
import com.rdesk.applications.productivity.code.CodeDesk;
@@ -88,6 +89,7 @@ public class Main {
launcher.addApp(new TabbedBrowser(), "Productivity");
launcher.addApp(new CodeDesk(), "Productivity");
launcher.addApp(new Calculator(), "Productivity");
+ launcher.addApp(new Xkcd(), "Entertainment");
launcher.addApp(new Exit(), "System");
launcher.addApp(new TaskManager(), "System");
launcher.addApp(new FileExplorerApp(), "System");
diff --git a/src/main/java/com/rdesk/applications/entertainment/comic/ComicData.java b/src/main/java/com/rdesk/applications/entertainment/comic/ComicData.java
new file mode 100644
index 0000000..9e4a22d
--- /dev/null
+++ b/src/main/java/com/rdesk/applications/entertainment/comic/ComicData.java
@@ -0,0 +1,27 @@
+package com.rdesk.applications.entertainment.comic;
+
+public class ComicData {
+ private int num;
+ private String title;
+ private String img;
+ private String alt;
+
+ // Gson requires default constructor
+ public ComicData() {}
+
+ public int getNum() {
+ return num;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getImg() {
+ return img;
+ }
+
+ public String getAlt() {
+ return alt;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/rdesk/applications/entertainment/comic/ComicFetcher.java b/src/main/java/com/rdesk/applications/entertainment/comic/ComicFetcher.java
new file mode 100644
index 0000000..ac0d607
--- /dev/null
+++ b/src/main/java/com/rdesk/applications/entertainment/comic/ComicFetcher.java
@@ -0,0 +1,58 @@
+package com.rdesk.applications.entertainment.comic;
+
+import com.google.gson.Gson;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+public class ComicFetcher {
+ private static final String BASE_URL = "https://xkcd.com/";
+ private static final String INFO_URL = BASE_URL + "info.0.json";
+ private static final String COMIC_URL_TEMPLATE = BASE_URL + "%d/info.0.json";
+
+ private final HttpClient httpClient;
+ private final Gson gson;
+
+ public ComicFetcher() {
+ this.httpClient = HttpClient.newHttpClient();
+ this.gson = new Gson();
+ }
+
+ public int fetchLatestComicNumber() throws IOException, InterruptedException {
+ String json = fetchJson(INFO_URL);
+ LatestInfo info = gson.fromJson(json, LatestInfo.class);
+ if (info == null || info.num == 0) {
+ throw new IOException("api response error");
+ }
+ return info.num;
+ }
+
+ public ComicData fetchComicData(int num) throws IOException, InterruptedException {
+ String url = String.format(COMIC_URL_TEMPLATE, num);
+ String json = fetchJson(url);
+ ComicData comic = gson.fromJson(json, ComicData.class);
+ if (comic == null || comic.getImg() == null) {
+ throw new IOException("error , idk bro #" + num);
+ }
+ return comic;
+ }
+
+ private String fetchJson(String urlString) throws IOException, InterruptedException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(urlString))
+ .GET()
+ .build();
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() != 200) {
+ throw new IOException("HTTP " + response.statusCode() + " for " + urlString);
+ }
+ return response.body();
+ }
+
+ private static class LatestInfo {
+ int num;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/rdesk/applications/entertainment/comic/Xkcd.java b/src/main/java/com/rdesk/applications/entertainment/comic/Xkcd.java
new file mode 100644
index 0000000..19224e7
--- /dev/null
+++ b/src/main/java/com/rdesk/applications/entertainment/comic/Xkcd.java
@@ -0,0 +1,54 @@
+package com.rdesk.applications.entertainment.comic;
+
+import com.rdesk.kernel.DesktopApp;
+import com.rdesk.kernel.Kernel;
+
+import javax.swing.*;
+import java.awt.*;
+
+public class Xkcd implements DesktopApp {
+
+ @Override
+ public String getId() {
+ return "xkcd";
+ }
+
+ @Override
+ public String getName() {
+ return "XKCD";
+ }
+
+ @Override
+ public JInternalFrame createWindow(Kernel kernel) {
+ JInternalFrame frame = new JInternalFrame(
+ "XKCD",
+ true, true, true, true
+ );
+ frame.setSize(800, 700);
+ frame.setLayout(new BorderLayout(5, 5));
+
+ JPanel topPanel = new JPanel(new BorderLayout(5, 0));
+ JButton refreshButton = new JButton("Random Comic");
+ JLabel titleLabel = new JLabel(" ", SwingConstants.CENTER);
+ titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14));
+ topPanel.add(refreshButton, BorderLayout.WEST);
+ topPanel.add(titleLabel, BorderLayout.CENTER);
+ frame.add(topPanel, BorderLayout.NORTH);
+
+ ZoomableImagePanel imagePanel = new ZoomableImagePanel();
+ frame.add(imagePanel, BorderLayout.CENTER);
+
+ JTextArea altTextArea = new JTextArea(4, 40);
+ altTextArea.setEditable(false);
+ altTextArea.setLineWrap(true);
+ altTextArea.setWrapStyleWord(true);
+ altTextArea.setFont(new Font("SansSerif", Font.PLAIN, 12));
+ JScrollPane altScrollPane = new JScrollPane(altTextArea);
+ frame.add(altScrollPane, BorderLayout.SOUTH);
+
+ new XkcdController(refreshButton, titleLabel, altTextArea, imagePanel).loadRandomComic();
+
+ frame.setVisible(true);
+ return frame;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/rdesk/applications/entertainment/comic/XkcdController.java b/src/main/java/com/rdesk/applications/entertainment/comic/XkcdController.java
new file mode 100644
index 0000000..8cc2949
--- /dev/null
+++ b/src/main/java/com/rdesk/applications/entertainment/comic/XkcdController.java
@@ -0,0 +1,97 @@
+package com.rdesk.applications.entertainment.comic;
+
+import javax.swing.*;
+import java.awt.image.BufferedImage;
+import java.net.URL;
+import java.util.concurrent.ExecutionException;
+
+public class XkcdController {
+ private final JButton refreshButton;
+ private final JLabel titleLabel;
+ private final JTextArea altTextArea;
+ private final ZoomableImagePanel imagePanel;
+ private final ComicFetcher fetcher;
+
+ public XkcdController(JButton refreshButton, JLabel titleLabel,
+ JTextArea altTextArea, ZoomableImagePanel imagePanel) {
+ this.refreshButton = refreshButton;
+ this.titleLabel = titleLabel;
+ this.altTextArea = altTextArea;
+ this.imagePanel = imagePanel;
+ this.fetcher = new ComicFetcher();
+
+ refreshButton.addActionListener(e -> loadRandomComic());
+ }
+
+ public void loadRandomComic() {
+ setLoadingState(true);
+ SwingWorker worker = new SwingWorker<>() {
+ @Override
+ protected ComicData doInBackground() throws Exception {
+ int latest = fetcher.fetchLatestComicNumber();
+ int randomNum = (int) (Math.random() * latest) + 1;
+ return fetcher.fetchComicData(randomNum);
+ }
+
+ @Override
+ protected void done() {
+ setLoadingState(false);
+ try {
+ ComicData comic = get();
+ if (comic != null) {
+ displayComic(comic);
+ } else {
+ showError("cant load comic");
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ showError("Error: " + e.getCause().getMessage());
+ }
+ }
+ };
+ worker.execute();
+ }
+
+ private void displayComic(ComicData comic) {
+ titleLabel.setText(String.format("#%d: %s", comic.getNum(), comic.getTitle()));
+ altTextArea.setText(comic.getAlt());
+
+ SwingWorker imageWorker = new SwingWorker<>() {
+ @Override
+ protected BufferedImage doInBackground() throws Exception {
+ URL url = new URL(comic.getImg());
+ return javax.imageio.ImageIO.read(url);
+ }
+
+ @Override
+ protected void done() {
+ try {
+ BufferedImage img = get();
+ imagePanel.setImage(img);
+ } catch (Exception e) {
+ showError("failed to load image: " + e.getMessage());
+ imagePanel.clearImage();
+ }
+ }
+ };
+ imageWorker.execute();
+ }
+
+ private void setLoadingState(boolean loading) {
+ SwingUtilities.invokeLater(() -> {
+ refreshButton.setEnabled(!loading);
+ if (loading) {
+ titleLabel.setText("fetching image");
+ altTextArea.setText("");
+ imagePanel.clearImage();
+ }
+ });
+ }
+
+ private void showError(String message) {
+ SwingUtilities.invokeLater(() -> {
+ JOptionPane.showMessageDialog(null, message, "Error", JOptionPane.ERROR_MESSAGE);
+ titleLabel.setText(" ");
+ altTextArea.setText("");
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/rdesk/applications/entertainment/comic/ZoomableImagePanel.java b/src/main/java/com/rdesk/applications/entertainment/comic/ZoomableImagePanel.java
new file mode 100644
index 0000000..34ac368
--- /dev/null
+++ b/src/main/java/com/rdesk/applications/entertainment/comic/ZoomableImagePanel.java
@@ -0,0 +1,136 @@
+package com.rdesk.applications.entertainment.comic;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseWheelEvent;
+import java.awt.image.BufferedImage;
+
+public class ZoomableImagePanel extends JPanel {
+ private BufferedImage originalImage;
+ private double zoom = 1.0;
+ private Point dragStart;
+ private Point viewportStart;
+ private final JViewport viewport;
+
+ private static final double ZOOM_STEP = 0.1;
+ private static final double MIN_ZOOM = 0.2;
+ private static final double MAX_ZOOM = 3.0;
+
+ public ZoomableImagePanel() {
+ setLayout(new BorderLayout());
+ JLabel imageLabel = new JLabel("", SwingConstants.CENTER);
+ JScrollPane scrollPane = new JScrollPane(imageLabel);
+ add(scrollPane, BorderLayout.CENTER);
+ this.viewport = scrollPane.getViewport();
+
+ imageLabel.addMouseWheelListener(this::onMouseWheel);
+
+ DragHandler dragHandler = new DragHandler(imageLabel);
+ imageLabel.addMouseListener(dragHandler);
+ imageLabel.addMouseMotionListener(dragHandler);
+ }
+
+ public void setImage(BufferedImage image) {
+ this.originalImage = image;
+ this.zoom = 1.0;
+ updateDisplay();
+ SwingUtilities.invokeLater(() -> viewport.setViewPosition(new Point(0, 0)));
+ }
+
+ public void clearImage() {
+ this.originalImage = null;
+ removeImageFromLabel();
+ }
+
+ private void updateDisplay() {
+ if (originalImage == null) {
+ removeImageFromLabel();
+ return;
+ }
+ JLabel label = getImageLabel();
+ int newWidth = (int) (originalImage.getWidth() * zoom);
+ int newHeight = (int) (originalImage.getHeight() * zoom);
+ Image scaled = originalImage.getScaledInstance(newWidth, newHeight, Image.SCALE_SMOOTH);
+ label.setIcon(new ImageIcon(scaled));
+ label.setText("");
+ label.setPreferredSize(new Dimension(newWidth, newHeight));
+ revalidate();
+ repaint();
+ }
+
+ private void onMouseWheel(MouseWheelEvent e) {
+ if (originalImage == null) return;
+ double oldZoom = zoom;
+ double newZoom = zoom + (e.getWheelRotation() < 0 ? ZOOM_STEP : -ZOOM_STEP);
+ newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
+ if (newZoom == zoom) return;
+
+ Point mouseOnScreen = e.getPoint();
+ Point viewPos = viewport.getViewPosition();
+ double xRatio = (viewPos.x + mouseOnScreen.x) / (double) viewport.getExtentSize().width;
+ double yRatio = (viewPos.y + mouseOnScreen.y) / (double) viewport.getExtentSize().height;
+
+ zoom = newZoom;
+ updateDisplay();
+
+ Dimension contentSize = getImageLabel().getPreferredSize();
+ Dimension viewSize = viewport.getExtentSize();
+ int newX = (int) (contentSize.width * xRatio - viewSize.width / 2.0);
+ int newY = (int) (contentSize.height * yRatio - viewSize.height / 2.0);
+ newX = Math.max(0, Math.min(newX, contentSize.width - viewSize.width));
+ newY = Math.max(0, Math.min(newY, contentSize.height - viewSize.height));
+ viewport.setViewPosition(new Point(newX, newY));
+ }
+
+ private JLabel getImageLabel() {
+ return (JLabel) ((JScrollPane) getComponent(0)).getViewport().getView();
+ }
+
+ private void removeImageFromLabel() {
+ JLabel label = getImageLabel();
+ label.setIcon(null);
+ label.setText("Loading");
+ label.setPreferredSize(null);
+ revalidate();
+ }
+
+ private class DragHandler extends MouseAdapter {
+ private final JLabel label;
+
+ DragHandler(JLabel label) {
+ this.label = label;
+ }
+
+ @Override
+ public void mousePressed(MouseEvent e) {
+ if (!SwingUtilities.isLeftMouseButton(e)) return;
+ dragStart = e.getPoint();
+ viewportStart = viewport.getViewPosition();
+ label.setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR));
+ }
+
+ @Override
+ public void mouseReleased(MouseEvent e) {
+ if (!SwingUtilities.isLeftMouseButton(e)) return;
+ dragStart = null;
+ viewportStart = null;
+ label.setCursor(Cursor.getDefaultCursor());
+ }
+
+ @Override
+ public void mouseDragged(MouseEvent e) {
+ if (dragStart == null || viewportStart == null) return;
+ int deltaX = dragStart.x - e.getX();
+ int deltaY = dragStart.y - e.getY();
+ Point newPos = new Point(viewportStart.x + deltaX, viewportStart.y + deltaY);
+
+ Dimension contentSize = label.getPreferredSize();
+ Dimension viewSize = viewport.getExtentSize();
+ newPos.x = Math.max(0, Math.min(newPos.x, contentSize.width - viewSize.width));
+ newPos.y = Math.max(0, Math.min(newPos.y, contentSize.height - viewSize.height));
+ viewport.setViewPosition(newPos);
+ }
+ }
+}