summaryrefslogtreecommitdiff
path: root/invest-applet/invest
diff options
context:
space:
mode:
Diffstat (limited to 'invest-applet/invest')
-rw-r--r--invest-applet/invest/Makefile.am36
-rw-r--r--invest-applet/invest/invest-applet-chart.c699
-rw-r--r--invest-applet/invest/invest-applet-chart.h35
-rw-r--r--invest-applet/invest/invest-applet.c722
-rw-r--r--invest-applet/invest/invest-applet.h77
5 files changed, 1569 insertions, 0 deletions
diff --git a/invest-applet/invest/Makefile.am b/invest-applet/invest/Makefile.am
new file mode 100644
index 00000000..2ee22ab1
--- /dev/null
+++ b/invest-applet/invest/Makefile.am
@@ -0,0 +1,36 @@
+NULL =
+
+AM_CPPFLAGS = \
+ $(WARN_FLAGS) \
+ $(MATE_APPLETS4_CFLAGS) \
+ $(LIBSOUP_CFLAGS) \
+ $(JSON_GLIB_CFLAGS) \
+ -I$(srcdir) \
+ $(DISABLE_DEPRECATED_CFLAGS) \
+ $(NULL)
+
+APPLET_SOURCES = \
+ invest-applet.c \
+ invest-applet-chart.c \
+ $(NULL)
+
+APPLET_LIBS = \
+ $(MATE_APPLETS4_LIBS) \
+ $(LIBSOUP_LIBS) \
+ $(JSON_GLIB_LIBS) \
+ $(NULL)
+
+if ENABLE_IN_PROCESS
+pkglib_LTLIBRARIES = libinvest-applet.la
+libinvest_applet_la_SOURCES = $(APPLET_SOURCES)
+libinvest_applet_la_CFLAGS = $(AM_CFLAGS)
+libinvest_applet_la_LDFLAGS = -module -avoid-version
+libinvest_applet_la_LIBADD = $(APPLET_LIBS)
+else !ENABLE_IN_PROCESS
+libexec_PROGRAMS = invest-applet
+invest_applet_SOURCES = $(APPLET_SOURCES)
+invest_applet_CFLAGS = $(AM_CFLAGS)
+invest_applet_LDADD = $(APPLET_LIBS)
+endif !ENABLE_IN_PROCESS
+
+-include $(top_srcdir)/git.mk \ No newline at end of file
diff --git a/invest-applet/invest/invest-applet-chart.c b/invest-applet/invest/invest-applet-chart.c
new file mode 100644
index 00000000..1114b900
--- /dev/null
+++ b/invest-applet/invest/invest-applet-chart.c
@@ -0,0 +1,699 @@
+/*
+ * MATE Invest Applet - Chart functionality
+ * Copyright (C) 2025 MATE developers
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include <config.h>
+#include <glib.h>
+#include <glib/gi18n.h>
+#include <gtk/gtk.h>
+#include <json-glib/json-glib.h>
+#include <libsoup/soup.h>
+#include <cairo/cairo.h>
+#include <mate-panel-applet.h>
+#include <mate-panel-applet-gsettings.h>
+#include "invest-applet-chart.h"
+
+typedef struct _StockChartData StockChartData;
+
+struct _StockChartData {
+ gchar *symbol;
+ gdouble *prices;
+ gint64 *timestamps;
+ gint data_count;
+ gboolean valid;
+};
+
+struct _InvestChart {
+ InvestApplet *applet;
+ GtkWidget *window;
+ GtkWidget *drawing_area;
+ StockChartData *chart_data;
+ gint chart_data_count;
+ gchar *chart_range;
+ gchar *chart_interval;
+};
+
+static void fetch_chart_data (InvestChart *chart);
+static void on_chart_data_received (SoupSession *session, SoupMessage *msg, gpointer user_data);
+static gboolean chart_draw_cb (GtkWidget *widget, cairo_t *cr, InvestChart *chart);
+static gboolean chart_window_key_press (GtkWidget *widget, GdkEventKey *event, InvestChart *chart);
+static void chart_range_button_clicked (GtkWidget *widget, InvestChart *chart);
+static void create_chart_toolbar (InvestChart *chart, GtkWidget *parent);
+static void free_chart_data (InvestChart *chart);
+static void draw_loading_message (cairo_t *cr, gint width, gint height, const gchar *message);
+static void on_chart_window_destroy (GtkWidget *widget, InvestChart *chart);
+
+InvestChart*
+invest_chart_new (InvestApplet *applet)
+{
+ InvestChart *chart = g_malloc0 (sizeof (InvestChart));
+ chart->applet = applet;
+ chart->chart_data = NULL;
+ chart->chart_data_count = 0;
+ chart->chart_range = g_strdup ("1d");
+ chart->chart_interval = g_strdup ("1m");
+ return chart;
+}
+
+void
+invest_chart_free (InvestChart *chart)
+{
+ if (!chart) {
+ return;
+ }
+
+ if (chart->window) {
+ invest_chart_hide (chart);
+ }
+
+ free_chart_data (chart);
+ g_free (chart->chart_range);
+ g_free (chart->chart_interval);
+ g_free (chart);
+}
+
+static void
+on_chart_window_destroy (GtkWidget *widget, InvestChart *chart)
+{
+ chart->window = NULL;
+ chart->drawing_area = NULL;
+}
+
+void
+invest_chart_show (InvestChart *chart)
+{
+ if (chart->window && gtk_widget_get_visible (chart->window)) {
+ /* Chart is already visible, just bring it to front */
+ gtk_window_present (GTK_WINDOW (chart->window));
+ return;
+ }
+
+ /* Set default chart range if not already set */
+ if (!chart->chart_range) {
+ chart->chart_range = g_strdup ("1d");
+ chart->chart_interval = g_strdup ("1m");
+ }
+
+ /* Create chart window */
+ chart->window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
+ gtk_window_set_title (GTK_WINDOW (chart->window), _("Stock Chart"));
+ gtk_window_set_default_size (GTK_WINDOW (chart->window), 800, 500);
+ gtk_window_set_resizable (GTK_WINDOW (chart->window), TRUE);
+ gtk_window_set_modal (GTK_WINDOW (chart->window), FALSE);
+
+ g_signal_connect (chart->window, "destroy", G_CALLBACK (on_chart_window_destroy), chart);
+
+ /* Create main vertical box */
+ GtkWidget *main_vbox = gtk_box_new (GTK_ORIENTATION_VERTICAL, 5);
+ gtk_container_add (GTK_CONTAINER (chart->window), main_vbox);
+
+ /* Create toolbar with time range buttons */
+ create_chart_toolbar (chart, main_vbox);
+
+ /* Create drawing area for chart */
+ chart->drawing_area = gtk_drawing_area_new ();
+ gtk_widget_set_vexpand (chart->drawing_area, TRUE);
+ gtk_widget_set_hexpand (chart->drawing_area, TRUE);
+ g_signal_connect (chart->drawing_area, "draw", G_CALLBACK (chart_draw_cb), chart);
+
+ /* Add drawing area to main box */
+ gtk_box_pack_start (GTK_BOX (main_vbox), chart->drawing_area, TRUE, TRUE, 0);
+
+ /* We want to close window easily with Esc */
+ g_signal_connect (chart->window, "key-press-event", G_CALLBACK (chart_window_key_press), chart);
+
+ gtk_widget_set_can_focus (chart->window, TRUE);
+ gtk_widget_show_all (chart->window);
+
+ fetch_chart_data (chart);
+}
+
+void
+invest_chart_hide (InvestChart *chart)
+{
+ if (!chart->window) {
+ return;
+ }
+
+ gtk_widget_destroy (chart->window);
+ chart->window = NULL;
+ chart->drawing_area = NULL;
+}
+
+gboolean
+invest_chart_is_visible (InvestChart *chart)
+{
+ if (!chart || !chart->window) {
+ return FALSE;
+ }
+ return gtk_widget_get_visible (chart->window);
+}
+
+void
+invest_chart_refresh_data (InvestChart *chart)
+{
+ /* Only refresh if chart is visible */
+ if (chart && chart->window && gtk_widget_get_visible (chart->window)) {
+ fetch_chart_data (chart);
+ }
+}
+
+static void
+fetch_chart_data (InvestChart *chart)
+{
+ gchar **symbols;
+ gint symbol_count;
+
+ if (!G_IS_SETTINGS (chart->applet->settings)) {
+ g_warning ("Settings not available for chart data");
+ return;
+ }
+
+ symbols = g_settings_get_strv (chart->applet->settings, "stock-symbols");
+ if (!symbols || !symbols[0]) {
+ g_strfreev (symbols);
+ return;
+ }
+
+ symbol_count = g_strv_length (symbols);
+
+ /* Free existing chart data */
+ free_chart_data (chart);
+
+ /* Allocate chart data array */
+ chart->chart_data = g_malloc0 (symbol_count * sizeof (StockChartData));
+ chart->chart_data_count = symbol_count;
+
+ /* Initialize chart data for each stock symbol */
+ for (gint i = 0; i < symbol_count; i++) {
+ chart->chart_data[i].symbol = g_strdup (symbols[i]);
+ chart->chart_data[i].valid = FALSE;
+ chart->chart_data[i].prices = NULL;
+ chart->chart_data[i].timestamps = NULL;
+ chart->chart_data[i].data_count = 0;
+ }
+
+ /* This will show loading state */
+ if (chart->window && gtk_widget_get_visible (chart->window)) {
+ gtk_widget_queue_draw (chart->window);
+ }
+
+ /* Fetch chart the actual stock data */
+ for (gint i = 0; i < symbol_count; i++) {
+ const gchar *range = chart->chart_range ? chart->chart_range : "1d";
+ const gchar *interval = chart->chart_interval ? chart->chart_interval : "1m";
+ gchar *url = g_strdup_printf ("https://query2.finance.yahoo.com/v8/finance/chart/%s?interval=%s&range=%s", symbols[i], interval, range);
+ SoupMessage *msg = soup_message_new ("GET", url);
+
+ /* HACK: avoid rate limiting */
+ soup_message_headers_replace (msg->request_headers, "User-Agent",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36");
+
+ gpointer *user_data_with_index = g_malloc (2 * sizeof (gpointer));
+ user_data_with_index[0] = chart;
+ user_data_with_index[1] = GINT_TO_POINTER (i);
+
+ /* Queue the actual request to the Yahoo Finance API */
+ soup_session_queue_message (chart->applet->soup_session, msg, on_chart_data_received, user_data_with_index);
+ g_free (url);
+ }
+
+ g_strfreev (symbols);
+}
+
+static void
+on_chart_data_received (SoupSession *session, SoupMessage *msg, gpointer user_data)
+{
+ gpointer *user_data_with_index = (gpointer *)user_data;
+ InvestChart *chart = (InvestChart *)user_data_with_index[0];
+ gint symbol_index = GPOINTER_TO_INT (user_data_with_index[1]);
+
+ JsonParser *parser = NULL;
+ JsonNode *root;
+ JsonObject *root_obj;
+ GError *error = NULL;
+
+ if (msg->status_code != SOUP_STATUS_OK) {
+ g_warning ("Failed to fetch chart data for symbol %d: %s", symbol_index, msg->reason_phrase);
+ goto cleanup;
+ }
+
+ parser = json_parser_new ();
+ if (!json_parser_load_from_data (parser, msg->response_body->data,
+ msg->response_body->length, &error)) {
+ g_warning ("Failed to parse chart JSON for symbol %d: %s", symbol_index, error->message);
+ g_error_free (error);
+ goto cleanup;
+ }
+
+ root = json_parser_get_root (parser);
+ root_obj = json_node_get_object (root);
+
+ /* This was... fun. The structure for the chart data looks like this:
+ *
+ * {
+ * "chart": {
+ * "result": [
+ * {
+ * "timestamp": [ ... ],
+ * "indicators": {
+ * "quote": [{
+ * "close": [ ... ]
+ * }]
+ * }
+ * }
+ * ]
+ * }
+ * }
+ *
+ * The closing prices for each stock map to the same index of
+ * the corresponding timestamps, so we need to parse them all
+ * separately but store them together for display later.
+ */
+ if (json_object_has_member (root_obj, "chart")) {
+ JsonObject *chart_obj = json_object_get_object_member (root_obj, "chart");
+ if (json_object_has_member (chart_obj, "result") && !json_object_get_null_member (chart_obj, "result")) {
+ JsonArray *results = json_object_get_array_member (chart_obj, "result");
+ if (results && json_array_get_length (results) > 0) {
+ JsonObject *result = json_array_get_object_element (results, 0);
+ if (result) {
+ /* Parse timestamps */
+ if (json_object_has_member (result, "timestamp")) {
+ JsonArray *timestamps = json_object_get_array_member (result, "timestamp");
+ if (timestamps) {
+ gint timestamp_count = json_array_get_length (timestamps);
+ /* Allocate memory for all the timestamps */
+ chart->chart_data[symbol_index].timestamps = g_malloc0 (timestamp_count * sizeof (gint64));
+ chart->chart_data[symbol_index].data_count = timestamp_count;
+
+ /* Then store them */
+ for (gint i = 0; i < timestamp_count; i++) {
+ JsonNode *timestamp_node = json_array_get_element (timestamps, i);
+ if (json_node_get_value_type (timestamp_node) == G_TYPE_INT64) {
+ chart->chart_data[symbol_index].timestamps[i] = json_node_get_int (timestamp_node);
+ }
+ }
+ }
+ }
+
+ /* Parse price data */
+ if (json_object_has_member (result, "indicators")) {
+ JsonObject *indicators = json_object_get_object_member (result, "indicators");
+ if (json_object_has_member (indicators, "quote")) {
+ JsonArray *quotes = json_object_get_array_member (indicators, "quote");
+ if (quotes && json_array_get_length (quotes) > 0) {
+ /* For some reason quote is an array, but there's only one quote for each stock symbol */
+ JsonObject *quote = json_array_get_object_element (quotes, 0);
+ if (quote && json_object_has_member (quote, "close")) {
+ JsonArray *close_prices = json_object_get_array_member (quote, "close");
+ if (close_prices) {
+ gint price_count = json_array_get_length (close_prices);
+ /* Allocate memory for all the closing prices */
+ chart->chart_data[symbol_index].prices = g_malloc0 (price_count * sizeof (gdouble));
+
+ /* Then store them */
+ for (gint i = 0; i < price_count; i++) {
+ JsonNode *price_node = json_array_get_element (close_prices, i);
+ if (json_node_get_value_type (price_node) == G_TYPE_DOUBLE) {
+ chart->chart_data[symbol_index].prices[i] = json_node_get_double (price_node);
+ }
+ }
+ /* We assume that the existence of data constitutes valid data */
+ chart->chart_data[symbol_index].valid = TRUE;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+cleanup:
+ if (parser) {
+ g_object_unref (parser);
+ }
+
+ /* Redraw chart if window is still visible */
+ if (chart->window && gtk_widget_get_visible (chart->window)) {
+ gtk_widget_queue_draw (chart->window);
+ }
+
+ g_free (user_data_with_index);
+}
+
+static gboolean
+chart_draw_cb (GtkWidget *widget, cairo_t *cr, InvestChart *chart)
+{
+ gint width, height;
+ GtkAllocation allocation;
+ gtk_widget_get_size_request (widget, &width, &height);
+ if (width <= 0 || height <= 0) {
+ gtk_widget_get_allocated_size (widget, &allocation, NULL);
+ width = allocation.width;
+ height = allocation.height;
+ }
+
+ /* Clear background */
+ cairo_set_source_rgb (cr, 1.0, 1.0, 1.0);
+ cairo_paint (cr);
+
+ /* Check if we have any valid data to display */
+ gboolean has_valid_data = FALSE;
+ if (chart->chart_data && chart->chart_data_count > 0) {
+ for (gint i = 0; i < chart->chart_data_count; i++) {
+ if (chart->chart_data[i].valid && chart->chart_data[i].data_count > 0) {
+ has_valid_data = TRUE;
+ break;
+ }
+ }
+ }
+
+ if (!has_valid_data) {
+ draw_loading_message (cr, width, height, _("Loading chart data..."));
+ return FALSE;
+ }
+
+ /* Find min/max prices for scaling the chart properly */
+ gdouble min_price = G_MAXDOUBLE;
+ gdouble max_price = G_MINDOUBLE;
+ gint64 min_time = G_MAXINT64;
+ gint64 max_time = G_MININT64;
+
+ for (gint i = 0; i < chart->chart_data_count; i++) {
+ if (chart->chart_data[i].valid && chart->chart_data[i].data_count > 0) {
+ for (gint j = 0; j < chart->chart_data[i].data_count; j++) {
+ if (chart->chart_data[i].prices[j] > 0) {
+ min_price = MIN (min_price, chart->chart_data[i].prices[j]);
+ max_price = MAX (max_price, chart->chart_data[i].prices[j]);
+ }
+ }
+ if (chart->chart_data[i].timestamps[0] > 0 &&
+ chart->chart_data[i].timestamps[chart->chart_data[i].data_count - 1] > 0 &&
+ chart->chart_data[i].timestamps[0] < 9999999999) { /* HACK: This works, but there's probably a better way */
+ min_time = MIN (min_time, chart->chart_data[i].timestamps[0]);
+ max_time = MAX (max_time, chart->chart_data[i].timestamps[chart->chart_data[i].data_count - 1]);
+ }
+ }
+ }
+
+ if (min_price == G_MAXDOUBLE || max_price == G_MINDOUBLE || min_time == G_MAXINT64 || max_time == G_MININT64) {
+ draw_loading_message (cr, width, height, _("No chart data available"));
+ return FALSE;
+ }
+
+ /* Add some padding to price range */
+ gdouble price_range = max_price - min_price;
+ min_price -= price_range * 0.05;
+ max_price += price_range * 0.05;
+
+ /* Calculate scaling factors */
+ gdouble x_scale = (gdouble)(width - 100) / (max_time - min_time);
+ gdouble y_scale = (gdouble)(height - 100) / (max_price - min_price);
+
+ /* Draw grid */
+ cairo_set_source_rgb (cr, 0.9, 0.9, 0.9);
+ cairo_set_line_width (cr, 1.0);
+
+ /* Horizontal grid lines */
+ for (gint i = 0; i <= 10; i++) {
+ gdouble y = 50 + (gdouble)i * (height - 100) / 10;
+ cairo_move_to (cr, 50, y);
+ cairo_line_to (cr, width - 50, y);
+ cairo_stroke (cr);
+ }
+
+ /* Vertical grid lines */
+ for (gint i = 0; i <= 10; i++) {
+ gdouble x = 50 + (gdouble)i * (width - 100) / 10;
+ cairo_move_to (cr, x, 50);
+ cairo_line_to (cr, x, height - 50);
+ cairo_stroke (cr);
+ }
+
+ /* Draw price labels */
+ cairo_set_source_rgb (cr, 0.0, 0.0, 0.0);
+ cairo_select_font_face (cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL);
+ cairo_set_font_size (cr, 10);
+
+ for (gint i = 0; i <= 5; i++) {
+ gdouble price = max_price - (gdouble)i * (max_price - min_price) / 5; /* Inverted for correct Y-axis */
+ gdouble y = 50 + (gdouble)i * (height - 100) / 5;
+ gchar price_str[32];
+ g_snprintf (price_str, sizeof (price_str), "%.2f", price);
+ cairo_move_to (cr, 5, y + 3);
+ cairo_show_text (cr, price_str);
+ }
+
+ /* Draw time labels */
+ for (gint i = 0; i <= 5; i++) {
+ gint64 time = min_time + (gint64)i * (max_time - min_time) / 5;
+ gdouble x = 50 + (gdouble)i * (width - 100) / 5;
+
+ /* Convert timestamp to local time */
+ GDateTime *dt = g_date_time_new_from_unix_local (time);
+ gchar time_str[64];
+
+ /* Format based on time range */
+ if (chart->chart_range && g_strcmp0 (chart->chart_range, "1d") == 0) {
+ /* For today view, show time */
+ g_snprintf (time_str, sizeof (time_str), "%02d:%02d",
+ g_date_time_get_hour (dt), g_date_time_get_minute (dt));
+ } else if (chart->chart_range && (g_strcmp0 (chart->chart_range, "5d") == 0 ||
+ g_strcmp0 (chart->chart_range, "1mo") == 0 ||
+ g_strcmp0 (chart->chart_range, "3mo") == 0)) {
+ /* For weekly/monthly/quarterly views, show date */
+ g_snprintf (time_str, sizeof (time_str), "%02d/%02d",
+ g_date_time_get_month (dt), g_date_time_get_day_of_month (dt));
+ } else {
+ /* For yearly and longer views, show month/year */
+ g_snprintf (time_str, sizeof (time_str), "%02d/%d",
+ g_date_time_get_month (dt), g_date_time_get_year (dt));
+ }
+
+ cairo_move_to (cr, x - 20, height - 20);
+ cairo_show_text (cr, time_str);
+ g_date_time_unref (dt);
+ }
+
+ /* Draw stock price lines */
+ const gchar *colors[] = {
+ "#CC0000",
+ "#3465A4",
+ "#73D216",
+ "#FCE94F",
+ "#AD7FA8",
+ "#F57900",
+ "#C17D11",
+ "#555753"
+ };
+
+ /* Collect valid stocks and their current prices for sorting */
+ typedef struct {
+ gint index;
+ gdouble current_price;
+ } StockSortInfo;
+
+ StockSortInfo *sort_info = g_malloc0 (chart->chart_data_count * sizeof (StockSortInfo));
+ gint valid_count = 0;
+
+ for (gint i = 0; i < chart->chart_data_count; i++) {
+ if (chart->chart_data[i].valid && chart->chart_data[i].data_count > 0) {
+ sort_info[valid_count].index = i;
+ sort_info[valid_count].current_price = chart->chart_data[i].prices[chart->chart_data[i].data_count - 1];
+ valid_count++;
+ }
+ }
+
+ /* Sort by current price (highest to lowest) */
+ for (gint i = 0; i < valid_count - 1; i++) {
+ for (gint j = i + 1; j < valid_count; j++) {
+ if (sort_info[i].current_price < sort_info[j].current_price) {
+ StockSortInfo temp = sort_info[i];
+ sort_info[i] = sort_info[j];
+ sort_info[j] = temp;
+ }
+ }
+ }
+
+ /* Draw lines and legend in sorted order */
+ for (gint i = 0; i < valid_count; i++) {
+ gint stock_index = sort_info[i].index;
+
+ /* Set color for this stock */
+ gint color_idx = i % G_N_ELEMENTS (colors);
+ GdkRGBA color;
+ gdk_rgba_parse (&color, colors[color_idx]);
+ gdk_cairo_set_source_rgba (cr, &color);
+ cairo_set_line_width (cr, 2.0);
+
+ /* Draw price line */
+ gboolean first_point = TRUE;
+
+ for (gint j = 0; j < chart->chart_data[stock_index].data_count; j++) {
+ if (chart->chart_data[stock_index].prices[j] > 0 &&
+ chart->chart_data[stock_index].timestamps[j] > 0 &&
+ chart->chart_data[stock_index].timestamps[j] < 9999999999) { /* Just to be safe... */
+
+ gdouble x = 50 + (gdouble)j * (width - 100) / (chart->chart_data[stock_index].data_count - 1);
+ gdouble y = 50 + (max_price - chart->chart_data[stock_index].prices[j]) * y_scale;
+
+ if (first_point) {
+ cairo_move_to (cr, x, y);
+ first_point = FALSE;
+ } else {
+ cairo_line_to (cr, x, y);
+ }
+ }
+ }
+ cairo_stroke (cr);
+
+ /* Draw legend with current price */
+ gdouble current_price = chart->chart_data[stock_index].prices[chart->chart_data[stock_index].data_count - 1];
+ gchar *legend_text = g_strdup_printf ("%s: $%.2f", chart->chart_data[stock_index].symbol, current_price);
+ cairo_move_to (cr, width - 200, 30 + i * 20);
+ cairo_show_text (cr, legend_text);
+ g_free (legend_text);
+ }
+
+ g_free (sort_info);
+
+ return FALSE;
+}
+
+static gboolean
+chart_window_key_press (GtkWidget *widget, GdkEventKey *event, InvestChart *chart)
+{
+ if (event->keyval == GDK_KEY_Escape) {
+ invest_chart_hide (chart);
+ return TRUE;
+ }
+ return FALSE;
+}
+
+static void
+create_chart_toolbar (InvestChart *chart, GtkWidget *parent)
+{
+ /* Let's use a horizontal box as a toolbar */
+ GtkWidget *toolbar = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 5);
+ gtk_container_set_border_width (GTK_CONTAINER (toolbar), 5);
+ gtk_box_pack_start (GTK_BOX (parent), toolbar, FALSE, FALSE, 0);
+
+ GtkWidget *label = gtk_label_new (_("Time Range:"));
+ gtk_box_pack_start (GTK_BOX (toolbar), label, FALSE, FALSE, 5);
+
+ /* Buttons for different time ranges */
+ struct {
+ const gchar *text;
+ const gchar *range;
+ const gchar *interval;
+ } ranges[] = {
+ { "Today", "1d", "1m" },
+ { "Week", "5d", "5m" },
+ { "Month", "1mo", "30m" },
+ { "YTD", "ytd", "1d" },
+ { "Year", "1y", "1d" },
+ { "5Y", "5y", "1wk" },
+ { "All", "max", "1mo" }
+ };
+
+ for (gint i = 0; i < G_N_ELEMENTS (ranges); i++) {
+ GtkWidget *button = gtk_button_new_with_label (ranges[i].text);
+ gtk_widget_set_size_request (button, 60, 30);
+
+ /* Store range and interval data in button */
+ g_object_set_data (G_OBJECT (button), "range", (gpointer)ranges[i].range);
+ g_object_set_data (G_OBJECT (button), "interval", (gpointer)ranges[i].interval);
+
+ g_signal_connect (button, "clicked", G_CALLBACK (chart_range_button_clicked), chart);
+ gtk_box_pack_start (GTK_BOX (toolbar), button, FALSE, FALSE, 2);
+
+ /* Disable the currently-selected button */
+ if (chart->chart_range && g_strcmp0 (chart->chart_range, ranges[i].range) == 0) {
+ gtk_widget_set_sensitive (button, FALSE);
+ }
+ }
+
+ gtk_box_pack_start (GTK_BOX (toolbar), gtk_label_new (""), TRUE, TRUE, 0);
+}
+
+static void
+free_chart_data (InvestChart *chart)
+{
+ if (!chart->chart_data) {
+ return;
+ }
+
+ for (gint i = 0; i < chart->chart_data_count; i++) {
+ g_free (chart->chart_data[i].symbol);
+ g_free (chart->chart_data[i].prices);
+ g_free (chart->chart_data[i].timestamps);
+ }
+
+ g_free (chart->chart_data);
+ chart->chart_data = NULL;
+ chart->chart_data_count = 0;
+}
+
+static void
+chart_range_button_clicked (GtkWidget *widget, InvestChart *chart)
+{
+ /* Get range and interval from button data */
+ const gchar *range = (const gchar *)g_object_get_data (G_OBJECT (widget), "range");
+ const gchar *interval = (const gchar *)g_object_get_data (G_OBJECT (widget), "interval");
+
+ if (!range || !interval) {
+ return;
+ }
+
+ /* Update chart's range and interval */
+ g_free (chart->chart_range);
+ g_free (chart->chart_interval);
+ chart->chart_range = g_strdup (range);
+ chart->chart_interval = g_strdup (interval);
+
+ /* Re-enable all buttons and disable the newly-selected one */
+ GtkWidget *parent = gtk_widget_get_parent (widget);
+ if (GTK_IS_BOX (parent)) {
+ GList *children = gtk_container_get_children (GTK_CONTAINER (parent));
+ for (GList *l = children; l; l = l->next) {
+ GtkWidget *child = GTK_WIDGET (l->data);
+ if (GTK_IS_BUTTON (child)) {
+ gtk_widget_set_sensitive (child, TRUE);
+ }
+ }
+ g_list_free (children);
+ }
+
+ /* Disable the clicked button */
+ gtk_widget_set_sensitive (widget, FALSE);
+
+ /* Fetch new chart data with updated range */
+ fetch_chart_data (chart);
+}
+
+static void
+draw_loading_message (cairo_t *cr, gint width, gint height, const gchar *message)
+{
+ cairo_set_source_rgb (cr, 0.0, 0.0, 0.0);
+ cairo_select_font_face (cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL);
+ cairo_set_font_size (cr, 16);
+ cairo_move_to (cr, width / 2 - 50, height / 2);
+ cairo_show_text (cr, message);
+}
diff --git a/invest-applet/invest/invest-applet-chart.h b/invest-applet/invest/invest-applet-chart.h
new file mode 100644
index 00000000..3e14917d
--- /dev/null
+++ b/invest-applet/invest/invest-applet-chart.h
@@ -0,0 +1,35 @@
+/*
+ * MATE Invest Applet - Chart functionality header
+ * Copyright (C) 2025 MATE developers
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#ifndef INVEST_APPLET_CHART_H
+#define INVEST_APPLET_CHART_H
+
+#include <glib.h>
+#include <gtk/gtk.h>
+#include "invest-applet.h"
+typedef struct _InvestChart InvestChart;
+
+InvestChart* invest_chart_new (InvestApplet *applet);
+void invest_chart_free (InvestChart *chart);
+void invest_chart_show (InvestChart *chart);
+void invest_chart_hide (InvestChart *chart);
+gboolean invest_chart_is_visible (InvestChart *chart);
+void invest_chart_refresh_data (InvestChart *chart);
+
+#endif /* INVEST_APPLET_CHART_H */
diff --git a/invest-applet/invest/invest-applet.c b/invest-applet/invest/invest-applet.c
new file mode 100644
index 00000000..979d90fb
--- /dev/null
+++ b/invest-applet/invest/invest-applet.c
@@ -0,0 +1,722 @@
+/*
+ * MATE Invest Applet
+ * Copyright (C) 2004-2005 Raphael Slinckx
+ * Copyright (C) 2009-2010 Enrico Minack
+ * Copyright (C) 2012-2025 MATE developers
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include <config.h>
+#include <glib.h>
+#include <glib/gi18n.h>
+#include <gio/gio.h>
+#include <gtk/gtk.h>
+#include <json-glib/json-glib.h>
+#include <libsoup/soup.h>
+#include <mate-panel-applet.h>
+#include <mate-panel-applet-gsettings.h>
+#include "invest-applet.h"
+#include "invest-applet-chart.h"
+
+static gchar* create_stock_tooltip (InvestApplet *applet);
+static void free_stock_data (InvestApplet *applet);
+static gint get_valid_stock_indices (InvestApplet *applet, gint *valid_indices);
+static void update_applet_text (InvestApplet *applet, const gchar *message, gdouble change_percent);
+static gboolean invest_applet_cycle_stocks (InvestApplet *applet);
+static void display_stock_at_index (InvestApplet *applet, gint stock_index);
+static void clear_timeout (guint *timeout_id);
+
+G_DEFINE_TYPE (InvestApplet, invest_applet, PANEL_TYPE_APPLET);
+
+#define DEFAULT_UPDATE_INTERVAL 15 /* 15 minutes */
+#define DEFAULT_CYCLE_INTERVAL 5 /* 5 seconds */
+
+static void
+invest_applet_update_display (InvestApplet *applet)
+{
+ const gchar *direction_icon;
+
+ if (applet->stock_summary) {
+ if (applet->change_percent > 0) {
+ direction_icon = "invest_up";
+ } else if (applet->change_percent < 0) {
+ direction_icon = "invest_down";
+ } else {
+ direction_icon = "invest_neutral";
+ }
+ } else {
+ direction_icon = "invest_neutral";
+ }
+
+ gtk_label_set_text (GTK_LABEL (applet->label), applet->stock_summary);
+ gtk_image_set_from_icon_name (GTK_IMAGE (applet->direction_icon), direction_icon, GTK_ICON_SIZE_MENU);
+}
+
+static void
+on_stock_data_received (SoupSession *session,
+ SoupMessage *msg,
+ gpointer user_data)
+{
+ gpointer *user_data_with_index = (gpointer *)user_data;
+ InvestApplet *applet = INVEST_APPLET (user_data_with_index[0]);
+ gint symbol_index = GPOINTER_TO_INT (user_data_with_index[1]);
+
+ JsonParser *parser = NULL;
+ JsonNode *root;
+ JsonObject *root_obj;
+ GError *error = NULL;
+
+ if (msg->status_code != SOUP_STATUS_OK) {
+ g_warning ("Failed to fetch stock data for symbol %d: %s", symbol_index, msg->reason_phrase);
+ goto cleanup;
+ }
+
+ parser = json_parser_new ();
+ if (!json_parser_load_from_data (parser, msg->response_body->data,
+ msg->response_body->length, &error)) {
+ g_warning ("Failed to parse JSON for symbol %d: %s", symbol_index, error->message);
+ g_error_free (error);
+ goto cleanup;
+ }
+
+ root = json_parser_get_root (parser);
+ root_obj = json_node_get_object (root);
+
+ /* Parse data for single stock. The expected JSON from the Yahoo Finance API should look like this:
+ * {
+ * "chart": {
+ * "result": [{
+ * "meta": {
+ * "currency": "USD",
+ * "symbol": "IBM",
+ * "exchangeName": "NYQ",
+ * "instrumentType": "EQUITY",
+ * "regularMarketPrice": 288.998,
+ * "longName": "International Business Machines Corporation",
+ * "shortName": "International Business Machines",
+ * "previousClose": 291.2,
+ * ...
+ * },
+ * "timestamp": [...],
+ * "indicators": {...}
+ * }],
+ * "error": null
+ * }
+ * }
+ */
+ if (json_object_has_member (root_obj, "chart")) {
+ JsonObject *chart = json_object_get_object_member (root_obj, "chart");
+ if (json_object_has_member (chart, "result") && !json_object_get_null_member (chart, "result")) {
+ JsonArray *results = json_object_get_array_member (chart, "result");
+ if (results && json_array_get_length (results) > 0) {
+ JsonObject *result = json_array_get_object_element (results, 0);
+ if (result) {
+ JsonObject *meta = json_object_get_object_member (result, "meta");
+
+ if (meta) {
+ gdouble current_price = json_object_get_double_member (meta, "regularMarketPrice");
+ gdouble prev_close = json_object_get_double_member (meta, "previousClose");
+
+ if (current_price > 0 && prev_close > 0) {
+ gdouble change_percent = ((current_price - prev_close) / prev_close) * 100.0;
+
+ /* store individual stock data */
+ applet->stock_prices[symbol_index] = current_price;
+ applet->stock_changes[symbol_index] = change_percent;
+ applet->stock_valid[symbol_index] = TRUE;
+ }
+ }
+ }
+ }
+ }
+ }
+
+cleanup:
+ if (parser) {
+ g_object_unref (parser);
+ }
+ applet->pending_requests--;
+
+ if (applet->pending_requests == 0) {
+ if (applet->total_symbols > 0) {
+ clear_timeout (&applet->cycle_timeout_id);
+
+ applet->cycle_position = 0;
+
+ gint valid_indices[applet->total_symbols];
+ gint valid_count = get_valid_stock_indices (applet, valid_indices);
+
+ if (valid_count > 0) {
+ display_stock_at_index (applet, valid_indices[0]);
+
+ /* start cycle timer if we have multiple stocks */
+ if (!applet->cycle_timeout_id && valid_count > 1) {
+ applet->cycle_timeout_id = g_timeout_add_seconds (applet->cycle_interval, (GSourceFunc) invest_applet_cycle_stocks, applet);
+ }
+
+ gchar *tooltip_text = create_stock_tooltip (applet);
+ gtk_widget_set_tooltip_text (GTK_WIDGET (applet), tooltip_text);
+ g_free (tooltip_text);
+ } else {
+ update_applet_text (applet, _("No valid stock data"), 0.0);
+ }
+ } else {
+ update_applet_text (applet, _("No valid stock data"), 0.0);
+ }
+ }
+
+ g_free (user_data_with_index);
+}
+
+/* fetch stock data from Yahoo! Finance for all configured symbols */
+static gboolean
+invest_applet_update_stocks (gpointer user_data)
+{
+ InvestApplet *applet = INVEST_APPLET (user_data);
+ gchar **symbols;
+ gint symbol_count;
+
+ if (!G_IS_SETTINGS (applet->settings)) {
+ g_warning ("Settings not available yet");
+ return G_SOURCE_CONTINUE;
+ }
+
+ symbols = g_settings_get_strv (applet->settings, "stock-symbols");
+
+ if (!symbols || !symbols[0]) {
+ update_applet_text (applet, _("No stocks configured"), 0.0);
+ g_strfreev (symbols);
+ return G_SOURCE_CONTINUE;
+ }
+
+ symbol_count = g_strv_length (symbols);
+
+ free_stock_data (applet);
+
+ applet->pending_requests = symbol_count;
+ applet->total_symbols = symbol_count;
+ applet->stock_symbols = g_strdupv (symbols);
+ applet->stock_prices = g_malloc0 (symbol_count * sizeof (gdouble));
+ applet->stock_changes = g_malloc0 (symbol_count * sizeof (gdouble));
+ applet->stock_valid = g_malloc0 (symbol_count * sizeof (gboolean));
+
+ applet->cycle_position = 0;
+ clear_timeout (&applet->cycle_timeout_id);
+
+ /* initialize networking */
+ if (!applet->soup_session) {
+ applet->soup_session = soup_session_new ();
+ }
+
+ /* make separate request for each stock symbol */
+ for (gint i = 0; i < symbol_count; i++) {
+ gchar *url = g_strdup_printf ("https://query2.finance.yahoo.com/v8/finance/chart/%s", symbols[i]);
+ SoupMessage *msg = soup_message_new ("GET", url);
+
+ /* HACK: avoid rate limiting */
+ soup_message_headers_replace (msg->request_headers, "User-Agent",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36");
+
+ gpointer *user_data_with_index = g_malloc (2 * sizeof (gpointer));
+ user_data_with_index[0] = applet;
+ user_data_with_index[1] = GINT_TO_POINTER (i);
+
+ soup_session_queue_message (applet->soup_session, msg, on_stock_data_received, user_data_with_index);
+ g_free (url);
+ }
+
+ g_strfreev (symbols);
+ return G_SOURCE_CONTINUE;
+}
+
+static void
+invest_applet_preferences_cb (GtkAction *action,
+ InvestApplet *applet)
+{
+ GtkWidget *dialog, *content_area, *entry, *label, *spin_button, *refresh_label, *cycle_label, *cycle_spin;
+ GtkWidget *hbox1, *hbox2, *hbox3;
+ gchar **symbols;
+ gchar *symbols_text;
+ gint response, refresh_interval, cycle_interval;
+
+ dialog = gtk_dialog_new_with_buttons (_("Investment Applet Preferences"),
+ NULL,
+ GTK_DIALOG_MODAL,
+ _("_Cancel"), GTK_RESPONSE_CANCEL,
+ _("_OK"), GTK_RESPONSE_OK,
+ NULL);
+
+ gtk_dialog_set_default_response (GTK_DIALOG (dialog), GTK_RESPONSE_OK);
+ gtk_window_set_default_size (GTK_WINDOW (dialog), 350, 150);
+
+ content_area = gtk_dialog_get_content_area (GTK_DIALOG (dialog));
+ gtk_container_set_border_width (GTK_CONTAINER (content_area), 12);
+
+ /* Stock symbols */
+ hbox1 = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6);
+ label = gtk_label_new (_("Stock symbols:"));
+ gtk_label_set_xalign (GTK_LABEL (label), 0.0);
+ gtk_widget_set_size_request (label, 120, -1);
+ entry = gtk_entry_new ();
+ gtk_box_pack_start (GTK_BOX (hbox1), label, FALSE, FALSE, 0);
+ gtk_box_pack_start (GTK_BOX (hbox1), entry, TRUE, TRUE, 0);
+
+ /* Refresh interval */
+ hbox2 = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6);
+ refresh_label = gtk_label_new (_("Refresh interval (minutes):"));
+ gtk_label_set_xalign (GTK_LABEL (refresh_label), 0.0);
+ gtk_widget_set_size_request (refresh_label, 120, -1);
+ spin_button = gtk_spin_button_new_with_range (1, 60, 1);
+ gtk_spin_button_set_digits (GTK_SPIN_BUTTON (spin_button), 0);
+ gtk_box_pack_start (GTK_BOX (hbox2), refresh_label, FALSE, FALSE, 0);
+ gtk_box_pack_start (GTK_BOX (hbox2), spin_button, FALSE, FALSE, 0);
+
+ /* Cycle interval */
+ hbox3 = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6);
+ cycle_label = gtk_label_new (_("Cycle interval (seconds):"));
+ gtk_label_set_xalign (GTK_LABEL (cycle_label), 0.0);
+ gtk_widget_set_size_request (cycle_label, 120, -1);
+ cycle_spin = gtk_spin_button_new_with_range (1, 60, 1);
+ gtk_spin_button_set_digits (GTK_SPIN_BUTTON (cycle_spin), 0);
+ gtk_box_pack_start (GTK_BOX (hbox3), cycle_label, FALSE, FALSE, 0);
+ gtk_box_pack_start (GTK_BOX (hbox3), cycle_spin, FALSE, FALSE, 0);
+
+ if (!G_IS_SETTINGS (applet->settings)) {
+ g_warning ("Settings not available in preferences");
+ gtk_widget_destroy (dialog);
+ return;
+ }
+
+ symbols = g_settings_get_strv (applet->settings, "stock-symbols");
+ symbols_text = g_strjoinv (",", symbols);
+ gtk_entry_set_text (GTK_ENTRY (entry), symbols_text);
+ g_free (symbols_text);
+ g_strfreev (symbols);
+
+ refresh_interval = g_settings_get_int (applet->settings, "refresh-interval");
+ gtk_spin_button_set_value (GTK_SPIN_BUTTON (spin_button), refresh_interval);
+
+ cycle_interval = g_settings_get_int (applet->settings, "cycle-interval");
+ gtk_spin_button_set_value (GTK_SPIN_BUTTON (cycle_spin), cycle_interval);
+
+ /* Pack all rows into content area */
+ gtk_box_pack_start (GTK_BOX (content_area), hbox1, FALSE, FALSE, 6);
+ gtk_box_pack_start (GTK_BOX (content_area), hbox2, FALSE, FALSE, 6);
+ gtk_box_pack_start (GTK_BOX (content_area), hbox3, FALSE, FALSE, 6);
+
+ gtk_widget_show_all (dialog);
+
+ response = gtk_dialog_run (GTK_DIALOG (dialog));
+
+ if (response == GTK_RESPONSE_OK) {
+ const gchar *text = gtk_entry_get_text (GTK_ENTRY (entry));
+ gchar **new_symbols = g_strsplit (text, ",", -1);
+ gint new_refresh_interval = gtk_spin_button_get_value_as_int (GTK_SPIN_BUTTON (spin_button));
+ gint new_cycle_interval = gtk_spin_button_get_value_as_int (GTK_SPIN_BUTTON (cycle_spin));
+
+ /* trim whitespaces */
+ for (gint i = 0; new_symbols[i]; i++) {
+ g_strstrip (new_symbols[i]);
+ }
+
+ g_settings_set_strv (applet->settings, "stock-symbols", (const gchar * const *)new_symbols);
+ g_settings_set_int (applet->settings, "refresh-interval", new_refresh_interval);
+ g_settings_set_int (applet->settings, "cycle-interval", new_cycle_interval);
+ g_strfreev (new_symbols);
+
+ /* Update refresh interval if changed */
+ if (new_refresh_interval != applet->refresh_interval) {
+ clear_timeout (&applet->update_timeout_id);
+ applet->refresh_interval = new_refresh_interval;
+ applet->update_timeout_id = g_timeout_add_seconds (new_refresh_interval * 60,
+ invest_applet_update_stocks,
+ applet);
+ }
+
+ /* Update cycle interval if changed */
+ if (new_cycle_interval != applet->cycle_interval) {
+ clear_timeout (&applet->cycle_timeout_id);
+ applet->cycle_interval = new_cycle_interval;
+ /* Restart cycle timer with new interval */
+ if (applet->total_symbols > 0) {
+ applet->cycle_timeout_id = g_timeout_add_seconds (new_cycle_interval, (GSourceFunc) invest_applet_cycle_stocks, applet);
+ }
+ }
+
+ /* Trigger update */
+ invest_applet_update_stocks (applet);
+
+ /* Refresh chart data if chart is visible */
+ invest_chart_refresh_data (applet->chart);
+ }
+
+ gtk_widget_destroy (dialog);
+}
+
+static void
+invest_applet_refresh_cb (GtkAction *action,
+ InvestApplet *applet)
+{
+ invest_applet_update_stocks (applet);
+}
+
+static void
+invest_applet_show_chart_cb (GtkAction *action,
+ InvestApplet *applet)
+{
+ if (invest_chart_is_visible (applet->chart)) {
+ invest_chart_hide (applet->chart);
+ } else {
+ invest_chart_show (applet->chart);
+ }
+}
+
+static void
+invest_applet_help_cb (GtkAction *action,
+ InvestApplet *applet)
+{
+ GError *error = NULL;
+
+ gtk_show_uri_on_window (NULL, "help:mate-invest-applet", gtk_get_current_event_time (), &error);
+
+ if (error) {
+ GtkWidget *dialog = gtk_message_dialog_new (NULL,
+ GTK_DIALOG_DESTROY_WITH_PARENT,
+ GTK_MESSAGE_ERROR,
+ GTK_BUTTONS_CLOSE,
+ _("Could not display help"));
+ gtk_message_dialog_format_secondary_text (GTK_MESSAGE_DIALOG (dialog),
+ "%s", error->message);
+ gtk_dialog_run (GTK_DIALOG (dialog));
+ gtk_widget_destroy (dialog);
+ g_error_free (error);
+ }
+}
+
+static void
+invest_applet_about_cb (GtkAction *action,
+ InvestApplet *applet)
+{
+ const gchar *authors[] = {
+ "Raphael Slinckx <[email protected]>",
+ "Enrico Minack <[email protected]>",
+ "MATE developers",
+ NULL
+ };
+
+ gtk_show_about_dialog (NULL,
+ "program-name", _("Invest"),
+ "logo-icon-name", "mate-invest-applet",
+ "version", VERSION,
+ "comments", _("Track your invested money."),
+ "copyright", "Copyright \xc2\xa9 2004-2005 Raphael Slinckx\nCopyright \xc2\xa9 2009-2010 Enrico Minack\nCopyright \xc2\xa9 2012-2025 MATE developers",
+ "authors", authors,
+ NULL);
+}
+
+static gboolean
+invest_applet_cycle_stocks (InvestApplet *applet)
+{
+ if (applet->total_symbols == 0) {
+ return G_SOURCE_CONTINUE;
+ }
+
+ /* find valid stocks */
+ gint valid_indices[applet->total_symbols];
+ gint valid_count = get_valid_stock_indices (applet, valid_indices);
+
+ if (valid_count == 0) {
+ update_applet_text (applet, _("No valid stock data"), 0.0);
+ return G_SOURCE_CONTINUE;
+ }
+
+ if (valid_count == 1) {
+ /* single stock, nothing to cycle, stop timer */
+ return G_SOURCE_REMOVE;
+ }
+
+ /* multiple stocks, cycle to next stock */
+ applet->cycle_position = (applet->cycle_position + 1) % valid_count;
+
+ /* display current stock */
+ display_stock_at_index (applet, valid_indices[applet->cycle_position]);
+
+ return G_SOURCE_CONTINUE;
+}
+
+static gboolean
+invest_applet_button_press (GtkWidget *widget,
+ GdkEventButton *event,
+ InvestApplet *applet)
+{
+ if (event->button == 1 && event->type == GDK_BUTTON_PRESS) {
+ if (invest_chart_is_visible (applet->chart)) {
+ invest_chart_hide (applet->chart);
+ } else {
+ invest_chart_show (applet->chart);
+ }
+ return TRUE;
+ }
+
+ return FALSE;
+}
+
+static void
+invest_applet_destroy (GtkWidget *widget,
+ InvestApplet *applet)
+{
+ clear_timeout (&applet->update_timeout_id);
+ clear_timeout (&applet->cycle_timeout_id);
+
+ if (applet->chart) {
+ if (invest_chart_is_visible (applet->chart)) {
+ invest_chart_hide (applet->chart);
+ }
+ invest_chart_free (applet->chart);
+ applet->chart = NULL;
+ }
+
+ if (applet->soup_session) {
+ g_object_unref (applet->soup_session);
+ applet->soup_session = NULL;
+ }
+
+ if (applet->settings) {
+ g_object_unref (applet->settings);
+ applet->settings = NULL;
+ }
+
+ free_stock_data (applet);
+}
+
+static void
+invest_applet_init (InvestApplet *applet)
+{
+ GtkWidget *hbox;
+
+ /* Settings will be initialized in the factory function */
+ applet->settings = NULL;
+ applet->refresh_interval = DEFAULT_UPDATE_INTERVAL;
+ applet->cycle_interval = DEFAULT_CYCLE_INTERVAL;
+
+ applet->pending_requests = 0;
+ applet->total_symbols = 0;
+ applet->stock_symbols = NULL;
+ applet->stock_prices = NULL;
+ applet->stock_changes = NULL;
+ applet->stock_valid = NULL;
+
+ /* init stock cycling in panel display */
+ applet->cycle_position = 0;
+ applet->cycle_timeout_id = 0;
+
+ /* init chart functionality */
+ applet->chart = invest_chart_new (applet);
+
+ /* init networking */
+ applet->soup_session = soup_session_new ();
+
+ /* UI */
+ hbox = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 4);
+
+ applet->direction_icon = gtk_image_new_from_icon_name ("dialog-information", GTK_ICON_SIZE_MENU);
+ applet->label = gtk_label_new (_("Loading..."));
+
+ gtk_box_pack_start (GTK_BOX (hbox), applet->direction_icon, FALSE, FALSE, 0);
+ gtk_box_pack_start (GTK_BOX (hbox), applet->label, FALSE, FALSE, 0);
+
+ gtk_container_add (GTK_CONTAINER (applet), hbox);
+
+ g_signal_connect (G_OBJECT (applet), "button-press-event",
+ G_CALLBACK (invest_applet_button_press), applet);
+ g_signal_connect (G_OBJECT (applet), "destroy",
+ G_CALLBACK (invest_applet_destroy), applet);
+
+ const GtkActionEntry menu_actions[] = {
+ { "InvestRefresh", "view-refresh", N_("_Refresh"), NULL, NULL, G_CALLBACK (invest_applet_refresh_cb) },
+ { "InvestShowChart", "invest_neutral", N_("_Show Chart"), NULL, NULL, G_CALLBACK (invest_applet_show_chart_cb) },
+ { "InvestPreferences", "preferences-system", N_("_Preferences"), NULL, NULL, G_CALLBACK (invest_applet_preferences_cb) },
+ { "InvestHelp", "help-browser", N_("_Help"), NULL, NULL, G_CALLBACK (invest_applet_help_cb) },
+ { "InvestAbout", "help-about", N_("_About"), NULL, NULL, G_CALLBACK (invest_applet_about_cb) }
+ };
+
+ GtkActionGroup *action_group = gtk_action_group_new ("Invest Applet Actions");
+ gtk_action_group_set_translation_domain (action_group, GETTEXT_PACKAGE);
+ gtk_action_group_add_actions (action_group, menu_actions, G_N_ELEMENTS (menu_actions), applet);
+
+ const gchar *ui = "<menuitem name=\"Invest Refresh\" action=\"InvestRefresh\" />"
+ "<menuitem name=\"Invest Show Chart\" action=\"InvestShowChart\" />"
+ "<separator />"
+ "<menuitem name=\"Invest Preferences\" action=\"InvestPreferences\" />"
+ "<menuitem name=\"Invest Help\" action=\"InvestHelp\" />"
+ "<menuitem name=\"Invest About\" action=\"InvestAbout\" />";
+
+ mate_panel_applet_setup_menu (MATE_PANEL_APPLET (applet), ui, action_group);
+ g_object_unref (action_group);
+
+ gtk_widget_show_all (GTK_WIDGET (applet));
+}
+
+static void
+invest_applet_class_init (InvestAppletClass *class)
+{
+}
+
+static gboolean
+invest_applet_factory (MatePanelApplet *applet,
+ const gchar *iid,
+ gpointer data)
+{
+ if (!g_strcmp0 (iid, "InvestApplet")) {
+ InvestApplet *invest_applet = INVEST_APPLET (applet);
+
+#ifndef ENABLE_IN_PROCESS
+ g_set_application_name (_("Investment Applet"));
+#endif
+ gtk_window_set_default_icon_name ("mate-invest-applet");
+
+ /* Set applet flags first */
+ mate_panel_applet_set_flags (applet, MATE_PANEL_APPLET_EXPAND_MINOR);
+
+ /* Initialize settings after applet is set up */
+ invest_applet->settings = mate_panel_applet_settings_new (applet, "org.mate.panel.applet.invest");
+
+ /* Load refresh interval from settings */
+ if (G_IS_SETTINGS (invest_applet->settings)) {
+ invest_applet->refresh_interval = g_settings_get_int (invest_applet->settings, "refresh-interval");
+ invest_applet->cycle_interval = g_settings_get_int (invest_applet->settings, "cycle-interval");
+ }
+
+ /* Start periodic updates */
+ invest_applet->update_timeout_id = g_timeout_add_seconds (invest_applet->refresh_interval * 60,
+ invest_applet_update_stocks,
+ invest_applet);
+
+ /* Load initial data */
+ invest_applet_update_stocks (invest_applet);
+ invest_applet_update_display (invest_applet);
+
+ return TRUE;
+ }
+
+ return FALSE;
+}
+
+static gchar*
+create_stock_tooltip (InvestApplet *applet)
+{
+ GString *tooltip = g_string_new ("");
+ gint valid_count = 0;
+
+ g_string_append (tooltip, _("Portfolio Summary:\n"));
+
+ for (gint i = 0; i < applet->total_symbols; i++) {
+ if (applet->stock_valid[i]) {
+ g_string_append_printf (tooltip, "%s: $%.2f (%.2f%%)\n",
+ applet->stock_symbols[i],
+ applet->stock_prices[i],
+ applet->stock_changes[i]);
+ valid_count++;
+ } else {
+ g_string_append_printf (tooltip, "%s: No data\n",
+ applet->stock_symbols[i]);
+ }
+ }
+
+ if (valid_count == 0) {
+ g_string_free (tooltip, TRUE);
+ return g_strdup (_("No valid stock data"));
+ }
+
+ return g_string_free (tooltip, FALSE);
+}
+
+static void
+free_stock_data (InvestApplet *applet)
+{
+ g_strfreev (applet->stock_symbols);
+ g_free (applet->stock_prices);
+ g_free (applet->stock_changes);
+ g_free (applet->stock_valid);
+ g_free (applet->stock_summary);
+
+ applet->stock_symbols = NULL;
+ applet->stock_prices = NULL;
+ applet->stock_changes = NULL;
+ applet->stock_valid = NULL;
+ applet->stock_summary = NULL;
+}
+
+static gint
+get_valid_stock_indices (InvestApplet *applet, gint *valid_indices)
+{
+ gint valid_count = 0;
+
+ for (gint i = 0; i < applet->total_symbols; i++) {
+ if (applet->stock_valid[i]) {
+ valid_indices[valid_count] = i;
+ valid_count++;
+ }
+ }
+
+ return valid_count;
+}
+
+static void
+display_stock_at_index (InvestApplet *applet, gint stock_index)
+{
+ if (stock_index < 0 || stock_index >= applet->total_symbols || !applet->stock_symbols) {
+ g_warning ("Invalid stock index %d (total: %d)", stock_index, applet->total_symbols);
+ return;
+ }
+
+ // Remove the '=' symbol from the stock symbol (used for currency conversions)
+ gchar *stock_symbol = g_strdup (applet->stock_symbols[stock_index]);
+ if (strchr (applet->stock_symbols[stock_index], '=')) {
+ g_free (stock_symbol);
+ stock_symbol = g_strndup (applet->stock_symbols[stock_index], strchr (applet->stock_symbols[stock_index], '=') - applet->stock_symbols[stock_index]);
+ }
+ gchar *message = g_strdup_printf ("%s: $%.2f",
+ stock_symbol,
+ applet->stock_prices[stock_index]);
+ update_applet_text (applet, message, applet->stock_changes[stock_index]);
+ g_free (stock_symbol);
+ g_free (message);
+}
+
+static void
+clear_timeout (guint *timeout_id)
+{
+ if (*timeout_id) {
+ g_source_remove (*timeout_id);
+ *timeout_id = 0;
+ }
+}
+
+static void
+update_applet_text (InvestApplet *applet, const gchar *message, gdouble change_percent)
+{
+ g_free (applet->stock_summary);
+ applet->stock_summary = g_strdup (message);
+ applet->change_percent = change_percent;
+ invest_applet_update_display (applet);
+}
+
+PANEL_APPLET_FACTORY ("InvestAppletFactory",
+ INVEST_TYPE_APPLET,
+ "Investment applet",
+ invest_applet_factory,
+ NULL)
diff --git a/invest-applet/invest/invest-applet.h b/invest-applet/invest/invest-applet.h
new file mode 100644
index 00000000..ce610474
--- /dev/null
+++ b/invest-applet/invest/invest-applet.h
@@ -0,0 +1,77 @@
+/*
+ * MATE Invest Applet - Main applet header
+ * Copyright (C) 2025 MATE developers
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#ifndef INVEST_APPLET_H
+#define INVEST_APPLET_H
+
+#include <glib.h>
+#include <gtk/gtk.h>
+#include <mate-panel-applet.h>
+#include <libsoup/soup.h>
+
+G_BEGIN_DECLS
+
+typedef struct _InvestApplet InvestApplet;
+typedef struct _InvestAppletClass InvestAppletClass;
+
+struct _InvestApplet {
+ MatePanelApplet parent;
+
+ GtkWidget *label;
+ GtkWidget *direction_icon; /* icon for stock price going up/down/neutral */
+ GSettings *settings;
+ SoupSession *soup_session;
+
+ guint update_timeout_id;
+ gchar *stock_summary;
+ gdouble change_percent;
+ gint refresh_interval;
+ gint cycle_interval;
+
+ gint pending_requests;
+ gint total_symbols;
+ gchar **stock_symbols;
+ gdouble *stock_prices;
+ gdouble *stock_changes;
+ gboolean *stock_valid;
+
+ /* for cycling through multiple stocks */
+ gint cycle_position;
+ guint cycle_timeout_id;
+
+ /* Chart functionality */
+ struct _InvestChart *chart;
+};
+
+struct _InvestAppletClass {
+ MatePanelAppletClass parent_class;
+};
+
+#define INVEST_TYPE_APPLET (invest_applet_get_type())
+#define INVEST_APPLET(obj) (G_TYPE_CHECK_INSTANCE_CAST((obj), INVEST_TYPE_APPLET, InvestApplet))
+#define INVEST_APPLET_CLASS(klass) (G_TYPE_CHECK_CLASS_CAST((klass), INVEST_TYPE_APPLET, InvestAppletClass))
+#define INVEST_IS_APPLET(obj) (G_TYPE_CHECK_INSTANCE_TYPE((obj), INVEST_TYPE_APPLET))
+#define INVEST_IS_APPLET_CLASS(klass) (G_TYPE_CHECK_CLASS_TYPE((klass), INVEST_TYPE_APPLET))
+#define INVEST_APPLET_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS((obj), INVEST_TYPE_APPLET, InvestAppletClass))
+
+GType invest_applet_get_type(void);
+
+G_END_DECLS
+
+#endif /* INVEST_APPLET_H */