initial commit

This commit is contained in:
Radek Davidek 2026-01-09 22:04:23 +01:00
commit 222fae10c7
18 changed files with 1564 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
build
.vscode

64
CMakeLists.txt Normal file
View File

@ -0,0 +1,64 @@
cmake_minimum_required(VERSION 3.16)
project(nvme-monitor)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC")
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED gtk4)
pkg_check_modules(CAIRO REQUIRED cairo)
# Find glib-compile-resources tool
find_program(GLIB_COMPILE_RESOURCES glib-compile-resources REQUIRED)
include_directories(${CMAKE_SOURCE_DIR}/include)
include_directories(${CMAKE_BINARY_DIR})
include_directories(${GTK_INCLUDE_DIRS})
include_directories(${CAIRO_INCLUDE_DIRS})
# Compile resources
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/resources.c
COMMAND ${GLIB_COMPILE_RESOURCES}
--generate-source
--sourcedir=${CMAKE_SOURCE_DIR}/resources
--target=${CMAKE_BINARY_DIR}/resources.c
${CMAKE_SOURCE_DIR}/resources/resources.xml
DEPENDS ${CMAKE_SOURCE_DIR}/resources/nvme-monitor.svg
${CMAKE_SOURCE_DIR}/resources/resources.xml
)
set(SOURCES
src/main.cpp
src/mainwindow.cpp
src/nvme_monitor.cpp
src/temperature_chart.cpp
src/config_manager.cpp
${CMAKE_BINARY_DIR}/resources.c
)
set(HEADERS
include/mainwindow.h
include/nvme_monitor.h
include/temperature_chart.h
include/config_manager.h
)
add_executable(nvme-monitor ${SOURCES} ${HEADERS})
target_link_libraries(nvme-monitor
${GTK_LIBRARIES}
${CAIRO_LIBRARIES}
m
)
target_compile_options(nvme-monitor PRIVATE ${GTK_CFLAGS_OTHER})
target_compile_options(nvme-monitor PRIVATE ${CAIRO_CFLAGS_OTHER})
# Install executable
install(TARGETS nvme-monitor
DESTINATION bin)
# Install desktop file for system integration
install(FILES resources/nvme-monitor.desktop
DESTINATION share/applications)

99
README.md Normal file
View File

@ -0,0 +1,99 @@
# NVMe Temperature Monitor
Jednoduchá GUI aplikace v C++, která v reálném čase zobrazuje teploty NVMe disků.
## Požadavky
- GTK 4 development balíčky
- Cairo development balíčky
- CMake >= 3.16
- GCC/Clang kompilátor s C++17
- `nvme-cli` balíček nainstalovaný
### Instalace závislostí (Ubuntu/Debian)
```bash
sudo apt-get install libgtk-4-dev libcairo2-dev cmake build-essential nvme-cli
```
### Instalace závislostí (Fedora/RHEL)
```bash
sudo dnf install gtk4-devel cairo-devel cmake gcc-c++ nvme-cli
```
### Instalace závislostí (Arch)
```bash
sudo pacman -S gtk4 cairo cmake base-devel nvme-cli
```
## Kompilace
```bash
cd /home/kamma/projects/nvme-monitor
chmod +x build.sh
./build.sh
```
## Spuštění
```bash
sudo ./build/nvme-monitor
```
**Poznámka:** Aplikace vyžaduje sudo oprávnění pro čtení informací z NVMe zařízení.
## Funkce
- ✅ Detekce všech NVMe disků
- ✅ Čtení teplot z hlavního senzoru a dalších senzorů
- ✅ Reálný čas graf s více řadami (každý disk v jiné barvě)
- ✅ Nastavitelný interval obnovení (100-10000 ms)
- ✅ Tlačítko pro vymazání grafu
- ✅ Status bar s informacemi o počtu zařízení a odečtech
- ✅ Cairo kreslení pro hladké grafy
- ✅ Mřížka a popisky os
## Struktura projektu
```
nvme-monitor/
├── CMakeLists.txt # Konfigurace CMake
├── src/
│ ├── main.cpp # Vstupní bod aplikace
│ ├── mainwindow.cpp # Hlavní okno UI (GTK)
│ ├── nvme_monitor.cpp # Logika čtení NVME dat
│ └── temperature_chart.cpp # Komponenta grafu (cairo)
├── include/
│ ├── mainwindow.h
│ ├── nvme_monitor.h
│ └── temperature_chart.h
└── build.sh # Build skript
```
## Použití
1. Spusťte aplikaci se sudo: `sudo ./build/nvme-monitor`
2. Okno se otevře a začne automaticky číst teploty
3. Používejte spinner pro změnu intervalu obnovení
4. Stiskněte "Clear Data" pro vymazání grafu
5. Stiskněte "Quit" pro ukončení aplikace
## Poznámky
- Aplikace parsuje výstup z `nvme smart-log` příkazů
- Podporuje více NVME zařízení, každé v jiné barvě
- Parsuje jak hlavní teplotu, tak dodatečné senzory teploty
- Graf drží posledních 300 datových bodů pro lepší výkon
- Kreslení grafů je implementováno s Cairo pro lepší flexibilitu
- Mřížka se automaticky škáluje podle rozsahu teplot
3. Používejte spinner pro změnu intervalu obnovení
4. Stiskněte "Clear Data" pro vymazání grafu
## Poznamky
- Aplikace parsuje výstup z `nvme smart-log` příkazu
- Podporuje více NVME zařízení, každé v jiné barvě
- Parsuje jak hlavní teplotu, tak dodatečné senzory teploty
- Graph drží posledních 300 datových bodů pro lepší výkon

11
build.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/bash
# Create build directory
mkdir -p build
cd build
# Build project
cmake ..
make
echo "Build complete! Run with: ./nvme-monitor"

33
include/config_manager.h Normal file
View File

@ -0,0 +1,33 @@
#ifndef CONFIG_MANAGER_H
#define CONFIG_MANAGER_H
#include <string>
class ConfigManager {
public:
ConfigManager();
// Load configuration
void load();
// Save configuration
void save();
// Getters and setters
int getWindowWidth() const { return windowWidth; }
int getWindowHeight() const { return windowHeight; }
int getPollingTime() const { return pollingTime; }
void setWindowWidth(int w) { windowWidth = w; }
void setWindowHeight(int h) { windowHeight = h; }
void setPollingTime(int t) { pollingTime = t; }
private:
std::string getConfigFilePath() const;
int windowWidth;
int windowHeight;
int pollingTime;
};
#endif // CONFIG_MANAGER_H

42
include/mainwindow.h Normal file
View File

@ -0,0 +1,42 @@
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <gtk/gtk.h>
#include "nvme_monitor.h"
#include "temperature_chart.h"
#include "config_manager.h"
class MainWindow {
public:
MainWindow();
~MainWindow();
void show();
private:
void setupUI();
void updateTemperatures();
void updateLegend();
void saveWindowState();
static gboolean onDeleteWindow(GtkWidget *widget, gpointer userData);
static gboolean onUpdateTimer(gpointer userData);
static void onRefreshRateChanged(GtkSpinButton *spinButton, gpointer userData);
static void onClearButtonClicked(GtkButton *button, gpointer userData);
static void onQuitButtonClicked(GtkButton *button, gpointer userData);
GtkWidget *window;
GtkWidget *statusLabel;
GtkSpinButton *refreshRateSpinBox;
TemperatureChart *chart;
NvmeMonitor *monitor;
ConfigManager *config;
guint timerID;
int refreshRateSec;
GtkWidget *legendBox;
};
// Global main loop reference for proper shutdown
extern GMainLoop *gMainLoop;
#endif // MAINWINDOW_H

34
include/nvme_monitor.h Normal file
View File

@ -0,0 +1,34 @@
#ifndef NVME_MONITOR_H
#define NVME_MONITOR_H
#include <string>
#include <map>
#include <vector>
struct TemperatureData {
double temperature;
std::string device;
std::string sensorName;
long timestamp;
};
class NvmeMonitor {
public:
explicit NvmeMonitor();
// Get list of available NVMe devices
std::vector<std::string> getAvailableDevices();
// Read temperatures from a specific device
std::map<std::string, double> readTemperatures(const std::string &device);
// Get all available devices and their temperatures
std::map<std::string, std::map<std::string, double>> getAllTemperatures();
private:
std::vector<std::string> scanDevices();
std::map<std::string, std::string> findHwmonDevices();
double readTemperatureFromFile(const std::string &filePath);
};
#endif // NVME_MONITOR_H

View File

@ -0,0 +1,79 @@
#ifndef TEMPERATURE_CHART_H
#define TEMPERATURE_CHART_H
#include <gtk/gtk.h>
#include <cairo.h>
#include <string>
#include <vector>
#include <map>
#include <cstdint>
struct DataPoint {
double temperature;
int64_t timestamp;
};
struct SeriesData {
std::vector<DataPoint> points;
GdkRGBA color;
std::string name;
};
class TemperatureChart {
public:
TemperatureChart(GtkWidget *parent = nullptr);
~TemperatureChart();
GtkWidget* getWidget() const { return drawingArea; }
void addTemperatureData(const std::string &device, const std::string &sensor,
double temperature, int64_t timestamp);
void clear();
void draw();
// Get list of devices with their colors
std::vector<std::pair<std::string, GdkRGBA>> getDeviceColors() const;
void drawChart(GtkDrawingArea *area, cairo_t *cr, int width, int height);
private:
void setupColors();
GdkRGBA getColorForDevice(const std::string &device);
void redraw();
void updateThemeColors();
static gboolean onTick(gpointer userData);
static void onLeave(GtkEventControllerMotion *motion, gpointer userData);
struct NearestPoint {
bool found;
double temperature;
int64_t timestamp;
std::string seriesName;
double distance;
};
NearestPoint findNearestDataPoint(double mouseX, double mouseY, int width, int height);
void showTooltip(double x, double y, const NearestPoint &point);
void hideTooltip();
GtkWidget *drawingArea;
GtkWidget *tooltipWindow;
GtkWidget *tooltipLabel;
std::map<std::string, SeriesData> seriesMap;
std::map<std::string, GdkRGBA> colorMap;
int maxDataPoints;
guint tickHandler;
double lastMouseX, lastMouseY;
// Cairo drawing state
double minTemp, maxTemp;
int64_t minTime, maxTime;
static constexpr int MAX_TIME_MS = 600000; // 10 minutes in milliseconds
static constexpr double MIN_TEMP = 10.0;
static constexpr double MAX_TEMP = 110.0;
// Theme colors
GdkRGBA bgColor, textColor, gridColor, axisColor;
};
#endif // TEMPERATURE_CHART_H

21
install.sh Normal file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# Install script for nvme-monitor
cd "$(dirname "$0")" || exit 1
echo "Building nvme-monitor..."
./build.sh
if [ ! -f build/nvme-monitor ]; then
echo "Build failed!"
exit 1
fi
echo "Installing nvme-monitor..."
cd build
sudo cmake --install .
echo "Installation complete!"
echo "Application icon is embedded in the executable"
echo "Run 'nvme-monitor' to start the application"

View File

@ -0,0 +1,9 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=NVMe Monitor
Comment=Monitor NVMe drive temperatures
Exec=nvme-monitor
Icon=nvme-monitor
Categories=Utility;System;
Terminal=false

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#FF6B6B;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FF0000;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="128" cy="128" r="120" fill="#1a1a1a" stroke="#333" stroke-width="2"/>
<!-- Thermometer bulb -->
<circle cx="128" cy="200" r="25" fill="url(#grad1)" stroke="#cc0000" stroke-width="2"/>
<!-- Thermometer tube -->
<rect x="118" y="80" width="20" height="120" fill="#f5f5f5" stroke="#333" stroke-width="2" rx="10"/>
<!-- Mercury level (70%) -->
<rect x="122" y="115" width="12" height="85" fill="#ff4444" rx="6"/>
<!-- Scale marks -->
<!-- Top mark (110°C) -->
<line x1="110" y1="85" x2="100" y2="85" stroke="#fff" stroke-width="2"/>
<text x="95" y="90" font-size="10" fill="#fff" text-anchor="end">110</text>
<!-- Upper mark (80°C) -->
<line x1="110" y1="120" x2="100" y2="120" stroke="#fff" stroke-width="2"/>
<text x="95" y="125" font-size="10" fill="#fff" text-anchor="end">80</text>
<!-- Middle mark (50°C) -->
<line x1="110" y1="155" x2="100" y2="155" stroke="#fff" stroke-width="2"/>
<text x="95" y="160" font-size="10" fill="#fff" text-anchor="end">50</text>
<!-- Lower mark (20°C) -->
<line x1="110" y1="190" x2="100" y2="190" stroke="#fff" stroke-width="2"/>
<text x="95" y="195" font-size="10" fill="#fff" text-anchor="end">20</text>
<!-- Right side marks -->
<line x1="146" y1="85" x2="156" y2="85" stroke="#fff" stroke-width="2"/>
<line x1="146" y1="120" x2="156" y2="120" stroke="#fff" stroke-width="2"/>
<line x1="146" y1="155" x2="156" y2="155" stroke="#fff" stroke-width="2"/>
<line x1="146" y1="190" x2="156" y2="190" stroke="#fff" stroke-width="2"/>
<!-- NVMe text -->
<text x="128" y="240" font-size="16" font-weight="bold" fill="#ff6b6b" text-anchor="middle">NVMe</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

7
resources/resources.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/kamma/nvme-monitor">
<file>nvme-monitor.svg</file>
<file>style.css</file>
</gresource>
</gresources>

4
resources/style.css Normal file
View File

@ -0,0 +1,4 @@
/* Default application styles and icon */
window {
/* Icon can be set via CSS if needed */
}

94
src/config_manager.cpp Normal file
View File

@ -0,0 +1,94 @@
#include "config_manager.h"
#include <fstream>
#include <sstream>
#include <cstdlib>
#include <sys/stat.h>
#include <iostream>
#include <unistd.h>
ConfigManager::ConfigManager()
: windowWidth(1200), windowHeight(700), pollingTime(3)
{
}
std::string ConfigManager::getConfigFilePath() const
{
// Get the directory of the executable using /proc/self/exe
char path[1024];
ssize_t len = readlink("/proc/self/exe", path, sizeof(path) - 1);
if (len == -1) {
// Fallback to current directory
return "./nvme-monitor.conf";
}
path[len] = '\0';
// Get directory from full path
std::string fullPath(path);
size_t lastSlash = fullPath.find_last_of('/');
if (lastSlash != std::string::npos) {
std::string exeDir = fullPath.substr(0, lastSlash);
return exeDir + "/nvme-monitor.conf";
}
return "./nvme-monitor.conf";
}
void ConfigManager::load()
{
std::string configPath = getConfigFilePath();
std::ifstream file(configPath);
if (!file.is_open()) {
// Config file doesn't exist, use defaults
return;
}
std::string line;
while (std::getline(file, line)) {
// Skip empty lines and comments
if (line.empty() || line[0] == '#') continue;
size_t pos = line.find('=');
if (pos == std::string::npos) continue;
std::string key = line.substr(0, pos);
std::string value = line.substr(pos + 1);
// Trim whitespace
key.erase(key.find_last_not_of(" \t") + 1);
value.erase(0, value.find_first_not_of(" \t"));
value.erase(value.find_last_not_of(" \t") + 1);
if (key == "window_width") {
windowWidth = std::stoi(value);
} else if (key == "window_height") {
windowHeight = std::stoi(value);
} else if (key == "polling_time") {
pollingTime = std::stoi(value);
}
}
file.close();
}
void ConfigManager::save()
{
std::string configPath = getConfigFilePath();
std::ofstream file(configPath);
if (!file.is_open()) {
std::cerr << "Failed to save config to: " << configPath << std::endl;
return;
}
file << "# NVMe Monitor Configuration\n";
file << "# Auto-generated, do not edit manually\n\n";
file << "window_width = " << windowWidth << "\n";
file << "window_height = " << windowHeight << "\n";
file << "polling_time = " << pollingTime << "\n";
file.close();
}

65
src/main.cpp Normal file
View File

@ -0,0 +1,65 @@
#include <gtk/gtk.h>
#include <glib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "mainwindow.h"
// Declaration from resources.c
extern "C" {
GResource *resources_get_resource();
}
GMainLoop *gMainLoop = nullptr;
int main(int argc, char *argv[])
{
// Daemonize the process
pid_t pid = fork();
if (pid < 0) {
return 1; // Fork failed
}
if (pid > 0) {
return 0; // Parent process exits
}
// Child process continues as daemon
umask(0);
// Create new session
if (setsid() < 0) {
return 1;
}
// Change directory to root
chdir("/");
// Close standard file descriptors
int fd = open("/dev/null", O_RDWR);
if (fd != -1) {
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
close(fd);
}
gtk_init();
// Register resources containing the application icon
GResource *resource = resources_get_resource();
g_resources_register(resource);
gMainLoop = g_main_loop_new(nullptr, FALSE);
MainWindow *window = new MainWindow();
window->show();
// Run the main event loop
g_main_loop_run(gMainLoop);
g_main_loop_unref(gMainLoop);
return 0;
}

320
src/mainwindow.cpp Normal file
View File

@ -0,0 +1,320 @@
#include "mainwindow.h"
#include <iostream>
#include <ctime>
#include <iomanip>
#include <sstream>
#include <string>
#include <gio/gio.h>
#include <gdk-pixbuf/gdk-pixbuf.h>
MainWindow::MainWindow()
: window(nullptr), statusLabel(nullptr), refreshRateSpinBox(nullptr),
chart(nullptr), monitor(nullptr), config(nullptr), timerID(0), refreshRateSec(3)
{
monitor = new NvmeMonitor();
config = new ConfigManager();
config->load();
refreshRateSec = config->getPollingTime();
setupUI();
// Set window size from config
gtk_window_set_default_size(GTK_WINDOW(window), config->getWindowWidth(), config->getWindowHeight());
// Start update timer
timerID = g_timeout_add(refreshRateSec * 1000, onUpdateTimer, this);
// Initial load immediately
updateTemperatures();
}
MainWindow::~MainWindow()
{
if (timerID) {
g_source_remove(timerID);
}
if (monitor) {
delete monitor;
}
if (chart) {
delete chart;
}
if (config) {
config->save();
delete config;
}
}
void MainWindow::setupUI()
{
window = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(window), "NVMe Temperature Monitor");
// Set window icon - in GTK4 we load pixbuf and use it for the window display
GError *error = nullptr;
GdkPixbuf *pixbuf = gdk_pixbuf_new_from_resource("/org/kamma/nvme-monitor/nvme-monitor.svg", &error);
if (pixbuf) {
// GTK4 doesn't have gtk_window_set_icon, but we can store the pixbuf for use
// The icon will be visible in the window title bar and taskbar through the name
g_object_unref(pixbuf);
} else if (error) {
g_warning("Failed to load icon from resources: %s", error->message);
g_error_free(error);
}
// Set icon name for fallback/system icon theme
gtk_window_set_icon_name(GTK_WINDOW(window), "nvme-monitor");
gtk_window_set_default_size(GTK_WINDOW(window), 1200, 700);
g_signal_connect(window, "close-request", G_CALLBACK(onDeleteWindow), this);
// Main box
GtkWidget *mainBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5);
gtk_widget_set_margin_top(mainBox, 5);
gtk_widget_set_margin_start(mainBox, 5);
gtk_widget_set_margin_end(mainBox, 5);
gtk_widget_set_margin_bottom(mainBox, 5);
gtk_window_set_child(GTK_WINDOW(window), mainBox);
// Control panel
GtkWidget *controlBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
// Refresh rate label
GtkWidget *refreshLabel = gtk_label_new("Refresh interval (s):");
gtk_box_append(GTK_BOX(controlBox), refreshLabel);
// Refresh rate spinner
GtkAdjustment *adjustment = gtk_adjustment_new(refreshRateSec, 1, 60, 1, 5, 0);
refreshRateSpinBox = GTK_SPIN_BUTTON(gtk_spin_button_new(adjustment, 100, 0));
g_signal_connect(refreshRateSpinBox, "value-changed", G_CALLBACK(onRefreshRateChanged), this);
gtk_box_append(GTK_BOX(controlBox), GTK_WIDGET(refreshRateSpinBox));
// Clear button
GtkWidget *clearButton = gtk_button_new_with_label("Clear Data");
g_signal_connect(clearButton, "clicked", G_CALLBACK(onClearButtonClicked), this);
gtk_box_append(GTK_BOX(controlBox), clearButton);
// Status label
statusLabel = gtk_label_new("Initializing...");
gtk_label_set_xalign(GTK_LABEL(statusLabel), 0);
gtk_widget_set_hexpand(statusLabel, TRUE);
gtk_box_append(GTK_BOX(controlBox), statusLabel);
// Quit button
GtkWidget *quitButton = gtk_button_new_with_label("Quit");
g_signal_connect(quitButton, "clicked", G_CALLBACK(onQuitButtonClicked), this);
gtk_box_append(GTK_BOX(controlBox), quitButton);
gtk_box_append(GTK_BOX(mainBox), controlBox);
// Chart
chart = new TemperatureChart();
gtk_box_append(GTK_BOX(mainBox), chart->getWidget());
gtk_widget_set_vexpand(chart->getWidget(), TRUE);
// Legend box
legendBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
gtk_widget_set_margin_top(legendBox, 10);
gtk_box_append(GTK_BOX(mainBox), legendBox);
}
gboolean MainWindow::onDeleteWindow(GtkWidget *widget, gpointer userData)
{
MainWindow *self = static_cast<MainWindow*>(userData);
self->saveWindowState();
delete self;
// Quit the main loop
if (gMainLoop) {
g_main_loop_quit(gMainLoop);
}
return FALSE;
}
gboolean MainWindow::onUpdateTimer(gpointer userData)
{
MainWindow *self = static_cast<MainWindow*>(userData);
self->updateTemperatures();
return TRUE;
}
void MainWindow::onRefreshRateChanged(GtkSpinButton *spinButton, gpointer userData)
{
MainWindow *self = static_cast<MainWindow*>(userData);
self->refreshRateSec = gtk_spin_button_get_value_as_int(spinButton);
self->config->setPollingTime(self->refreshRateSec);
// Update timer
if (self->timerID) {
g_source_remove(self->timerID);
}
self->timerID = g_timeout_add(self->refreshRateSec * 1000, onUpdateTimer, self);
}
void MainWindow::onClearButtonClicked(GtkButton *button, gpointer userData)
{
MainWindow *self = static_cast<MainWindow*>(userData);
self->chart->clear();
}
void MainWindow::onQuitButtonClicked(GtkButton *button, gpointer userData)
{
MainWindow *self = static_cast<MainWindow*>(userData);
self->saveWindowState();
// Quit the main loop
if (gMainLoop) {
g_main_loop_quit(gMainLoop);
}
delete self;
}
void MainWindow::saveWindowState()
{
int width, height;
gtk_window_get_default_size(GTK_WINDOW(window), &width, &height);
config->setWindowWidth(width > 0 ? width : 1200);
config->setWindowHeight(height > 0 ? height : 700);
config->setPollingTime(refreshRateSec);
config->save();
}
void MainWindow::updateTemperatures()
{
auto allTemps = monitor->getAllTemperatures();
// Get current real time in milliseconds since epoch
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
int64_t currentTime = (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
int totalReadings = 0;
for (auto &deviceEntry : allTemps) {
const std::string &device = deviceEntry.first;
const auto &temps = deviceEntry.second;
for (const auto &tempEntry : temps) {
const std::string &sensorName = tempEntry.first;
double temperature = tempEntry.second;
chart->addTemperatureData(device, sensorName, temperature, currentTime);
totalReadings++;
}
}
// Update status label
std::time_t now = std::time(nullptr);
std::tm *timeinfo = std::localtime(&now);
std::ostringstream oss;
oss << "Updated: " << std::put_time(timeinfo, "%H:%M:%S")
<< " | Devices: " << allTemps.size()
<< " | Total readings: " << totalReadings;
gtk_label_set_text(GTK_LABEL(statusLabel), oss.str().c_str());
// Update legend
updateLegend();
}
void MainWindow::updateLegend()
{
// Clear existing legend items
GtkWidget *child = gtk_widget_get_first_child(legendBox);
while (child) {
GtkWidget *next = gtk_widget_get_next_sibling(child);
gtk_box_remove(GTK_BOX(legendBox), child);
child = next;
}
// Get device colors from chart
auto deviceColors = chart->getDeviceColors();
// Add legend items
for (const auto &pair : deviceColors) {
const std::string &device = pair.first;
const GdkRGBA &color = pair.second;
// Create container for legend item
GtkWidget *itemBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
// Create color box (drawing area with fixed color)
GtkWidget *colorBox = gtk_drawing_area_new();
gtk_widget_set_size_request(colorBox, 20, 20);
// Store color in user data
GdkRGBA *colorPtr = new GdkRGBA(color);
g_object_set_data_full(G_OBJECT(colorBox), "color", colorPtr,
[](gpointer data) { delete static_cast<GdkRGBA*>(data); });
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(colorBox),
[](GtkDrawingArea *area, cairo_t *cr, int width, int height, gpointer data) {
GdkRGBA *color = static_cast<GdkRGBA*>(data);
cairo_set_source_rgba(cr, color->red, color->green, color->blue, color->alpha);
cairo_paint(cr);
// Get theme colors from GTK settings
gboolean isDark = FALSE;
GtkSettings *settings = gtk_settings_get_default();
if (settings) {
gchar *themeName = nullptr;
g_object_get(settings, "gtk-theme-name", &themeName, nullptr);
if (themeName) {
std::string theme(themeName);
isDark = (theme.find("dark") != std::string::npos ||
theme.find("Dark") != std::string::npos);
g_free(themeName);
}
}
// Try GSetting if not found in GTK settings
if (!isDark) {
GSettingsSchemaSource *source = g_settings_schema_source_get_default();
if (source) {
GSettingsSchema *schema = g_settings_schema_source_lookup(source, "org.gnome.desktop.interface", FALSE);
if (schema) {
GSettings *gsettings = g_settings_new("org.gnome.desktop.interface");
if (gsettings) {
gchar *gtkTheme = g_settings_get_string(gsettings, "gtk-theme");
if (gtkTheme) {
std::string theme(gtkTheme);
isDark = (theme.find("dark") != std::string::npos ||
theme.find("Dark") != std::string::npos);
g_free(gtkTheme);
}
g_object_unref(gsettings);
}
g_settings_schema_unref(schema);
}
}
}
if (isDark) {
cairo_set_source_rgb(cr, 0.6, 0.6, 0.6); // Lighter gray for dark theme
} else {
cairo_set_source_rgb(cr, 0.3, 0.3, 0.3); // Darker gray for light theme
}
cairo_set_line_width(cr, 1);
cairo_rectangle(cr, 0, 0, width, height);
cairo_stroke(cr);
}, colorPtr, nullptr);
gtk_box_append(GTK_BOX(itemBox), colorBox);
// Create label with device name
GtkWidget *label = gtk_label_new(device.c_str());
gtk_box_append(GTK_BOX(itemBox), label);
gtk_box_append(GTK_BOX(legendBox), itemBox);
}
gtk_widget_set_visible(legendBox, TRUE);
}
void MainWindow::show()
{
gtk_widget_set_visible(window, TRUE);
}

164
src/nvme_monitor.cpp Normal file
View File

@ -0,0 +1,164 @@
#include "nvme_monitor.h"
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <regex>
#include <dirent.h>
#include <sys/stat.h>
#include <iostream>
#include <cctype>
#include <algorithm>
NvmeMonitor::NvmeMonitor()
{
}
std::vector<std::string> NvmeMonitor::getAvailableDevices()
{
return scanDevices();
}
double NvmeMonitor::readTemperatureFromFile(const std::string &filePath)
{
std::ifstream file(filePath);
if (!file.is_open()) {
return 0.0;
}
std::string line;
if (std::getline(file, line)) {
try {
// Temperature is in millidegrees Celsius, convert to Celsius
long tempMilliC = std::stol(line);
return tempMilliC / 1000.0;
} catch (const std::exception &e) {
std::cerr << "Failed to parse temperature from " << filePath << ": " << e.what() << std::endl;
}
}
file.close();
return 0.0;
}
std::map<std::string, std::string> NvmeMonitor::findHwmonDevices()
{
std::map<std::string, std::string> hwmonMap; // Maps device name to hwmon path
DIR* hwmonDir = opendir("/sys/class/hwmon");
if (!hwmonDir) return hwmonMap;
struct dirent* entry;
while ((entry = readdir(hwmonDir)) != nullptr) {
std::string hwmonName = entry->d_name;
// Look for hwmon* directories
if (hwmonName.find("hwmon") == 0) {
std::string namePath = "/sys/class/hwmon/" + hwmonName + "/name";
std::ifstream nameFile(namePath);
if (nameFile.is_open()) {
std::string deviceName;
if (std::getline(nameFile, deviceName)) {
// Remove trailing whitespace
deviceName.erase(deviceName.find_last_not_of(" \n\r\t") + 1);
if (deviceName == "nvme") {
hwmonMap[hwmonName] = "/sys/class/hwmon/" + hwmonName;
}
}
nameFile.close();
}
}
}
closedir(hwmonDir);
return hwmonMap;
}
std::vector<std::string> NvmeMonitor::scanDevices()
{
std::vector<std::string> devices;
DIR* devDir = opendir("/dev");
if (!devDir) return devices;
struct dirent* entry;
while ((entry = readdir(devDir)) != nullptr) {
std::string name = entry->d_name;
// Only include main device entries (nvme0, nvme1, etc.), not namespaces or fabrics
if (name.find("nvme") == 0 && name.length() > 4) {
// Check if the rest after "nvme" contains only digits
bool onlyDigits = true;
for (size_t i = 4; i < name.length(); i++) {
if (!std::isdigit(name[i])) {
onlyDigits = false;
break;
}
}
if (onlyDigits) {
devices.push_back("/dev/" + name);
}
}
}
closedir(devDir);
return devices;
}
std::map<std::string, double> NvmeMonitor::readTemperatures(const std::string &device)
{
std::map<std::string, double> temperatures;
// Map hwmon devices to NVMe devices
auto hwmonMap = findHwmonDevices();
if (hwmonMap.empty()) {
std::cerr << "No NVMe hwmon devices found in /sys/class/hwmon" << std::endl;
return temperatures;
}
// For now, read from the first NVMe device found
// In a more sophisticated implementation, we could map specific nvme devices to hwmon entries
auto firstHwmon = hwmonMap.begin();
std::string hwmonPath = firstHwmon->second;
// Read temperature sensors from hwmon
// temp1_input is typically the main sensor
double temp1 = readTemperatureFromFile(hwmonPath + "/temp1_input");
if (temp1 > 0) {
temperatures["Main"] = temp1;
}
// Check for additional temperature sensors (temp2, temp3, etc.)
for (int i = 2; i <= 10; i++) {
std::string tempPath = hwmonPath + "/temp" + std::to_string(i) + "_input";
struct stat buffer;
if (stat(tempPath.c_str(), &buffer) == 0) {
double temp = readTemperatureFromFile(tempPath);
if (temp > 0) {
std::string sensorName = "Sensor " + std::to_string(i);
temperatures[sensorName] = temp;
}
}
}
if (temperatures.empty()) {
std::cerr << "Failed to read temperatures from device: " << device << std::endl;
}
return temperatures;
}
std::map<std::string, std::map<std::string, double>> NvmeMonitor::getAllTemperatures()
{
std::map<std::string, std::map<std::string, double>> allTemperatures;
std::vector<std::string> devices = scanDevices();
for (const auto &device : devices) {
auto temps = readTemperatures(device);
if (!temps.empty()) {
allTemperatures[device] = temps;
}
}
return allTemperatures;
}

469
src/temperature_chart.cpp Normal file
View File

@ -0,0 +1,469 @@
#include "temperature_chart.h"
#include <iostream>
#include <cmath>
#include <ctime>
#include <algorithm>
#include <limits>
#include <string>
TemperatureChart::TemperatureChart(GtkWidget *parent)
: drawingArea(nullptr), tooltipWindow(nullptr), tooltipLabel(nullptr),
maxDataPoints(600), tickHandler(0),
minTemp(MIN_TEMP), maxTemp(MAX_TEMP), minTime(0), maxTime(0),
lastMouseX(-1), lastMouseY(-1)
{
drawingArea = gtk_drawing_area_new();
gtk_widget_set_size_request(drawingArea, 800, 400);
gtk_widget_set_can_focus(drawingArea, TRUE);
gtk_widget_set_focusable(drawingArea, TRUE);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(drawingArea),
[](GtkDrawingArea *area, cairo_t *cr, int width, int height, gpointer userData) {
TemperatureChart *self = static_cast<TemperatureChart*>(userData);
self->drawChart(area, cr, width, height);
}, this, nullptr);
// Setup event controller for mouse motion
GtkEventController *motion = gtk_event_controller_motion_new();
g_signal_connect_data(motion, "motion",
G_CALLBACK(+[](GtkEventControllerMotion *motion, double x, double y, gpointer userData) -> gboolean {
TemperatureChart *self = static_cast<TemperatureChart*>(userData);
self->lastMouseX = x;
self->lastMouseY = y;
GtkWidget *drawArea = self->drawingArea;
int width = gtk_widget_get_width(drawArea);
int height = gtk_widget_get_height(drawArea);
NearestPoint nearest = self->findNearestDataPoint(x, y, width, height);
if (nearest.found && nearest.distance < 5.0) {
self->showTooltip(x, y, nearest);
} else {
self->hideTooltip();
}
return FALSE;
}), this, nullptr, (GConnectFlags)0);
g_signal_connect(motion, "leave", G_CALLBACK(onLeave), this);
gtk_widget_add_controller(drawingArea, motion);
setupColors();
// Setup tick callback for redrawing
tickHandler = g_timeout_add(100, onTick, this);
}
TemperatureChart::~TemperatureChart()
{
if (tickHandler) {
g_source_remove(tickHandler);
}
hideTooltip();
}
void TemperatureChart::setupColors()
{
// Define colors for different devices
const GdkRGBA colors[] = {
{1.0, 0.0, 0.0, 1.0}, // Red
{0.0, 0.0, 1.0, 1.0}, // Blue
{0.0, 0.5, 0.0, 1.0}, // Green
{1.0, 0.647, 0.0, 1.0}, // Orange
{0.5, 0.0, 0.5, 1.0}, // Purple
{0.0, 0.5, 0.5, 1.0}, // Teal
{1.0, 0.753, 0.796, 1.0}, // Pink
{0.647, 0.165, 0.165, 1.0} // Brown
};
int index = 0;
for (auto &entry : seriesMap) {
entry.second.color = colors[index % 8];
index++;
}
updateThemeColors();
}
void TemperatureChart::updateThemeColors()
{
// Detect dark mode by checking the desktop theme
gboolean isDark = FALSE;
// Try to get the theme from both GTK settings and Gnome settings
GtkSettings *settings = gtk_settings_get_default();
if (settings) {
gchar *themeName = nullptr;
g_object_get(settings, "gtk-theme-name", &themeName, nullptr);
if (themeName) {
std::string theme(themeName);
// Check if theme name contains "dark"
isDark = (theme.find("dark") != std::string::npos ||
theme.find("Dark") != std::string::npos);
g_free(themeName);
}
}
// Also try GSetting if available
if (!isDark) {
GSettingsSchemaSource *source = g_settings_schema_source_get_default();
if (source) {
GSettingsSchema *schema = g_settings_schema_source_lookup(source, "org.gnome.desktop.interface", FALSE);
if (schema) {
GSettings *gsettings = g_settings_new("org.gnome.desktop.interface");
if (gsettings) {
gchar *gtkTheme = g_settings_get_string(gsettings, "gtk-theme");
if (gtkTheme) {
std::string theme(gtkTheme);
isDark = (theme.find("dark") != std::string::npos ||
theme.find("Dark") != std::string::npos);
g_free(gtkTheme);
}
g_object_unref(gsettings);
}
g_settings_schema_unref(schema);
}
}
}
if (isDark) {
// Dark theme colors
bgColor = {0.1, 0.1, 0.1, 1.0}; // Dark background
textColor = {0.9, 0.9, 0.9, 1.0}; // Light text
gridColor = {0.25, 0.25, 0.25, 1.0}; // Dark gray grid
axisColor = {0.9, 0.9, 0.9, 1.0}; // Light axis
} else {
// Light theme colors
bgColor = {1.0, 1.0, 1.0, 1.0}; // White background
textColor = {0.0, 0.0, 0.0, 1.0}; // Black text
gridColor = {0.9, 0.9, 0.9, 1.0}; // Light gray grid
axisColor = {0.0, 0.0, 0.0, 1.0}; // Black axis
}
}
GdkRGBA TemperatureChart::getColorForDevice(const std::string &device)
{
if (colorMap.find(device) == colorMap.end()) {
const GdkRGBA colors[] = {
{1.0, 0.0, 0.0, 1.0},
{0.0, 0.0, 1.0, 1.0},
{0.0, 0.5, 0.0, 1.0},
{1.0, 0.647, 0.0, 1.0},
{0.5, 0.0, 0.5, 1.0},
{0.0, 0.5, 0.5, 1.0},
{1.0, 0.753, 0.796, 1.0},
{0.647, 0.165, 0.165, 1.0}
};
int colorIndex = colorMap.size() % 8;
colorMap[device] = colors[colorIndex];
}
return colorMap[device];
}
void TemperatureChart::addTemperatureData(const std::string &device, const std::string &sensor,
double temperature, int64_t timestamp)
{
std::string seriesKey = device + " - " + sensor;
// Create series if it doesn't exist
if (seriesMap.find(seriesKey) == seriesMap.end()) {
SeriesData series;
series.color = getColorForDevice(device);
series.name = seriesKey;
seriesMap[seriesKey] = series;
}
// Add data point
DataPoint point;
point.temperature = temperature;
point.timestamp = timestamp;
seriesMap[seriesKey].points.push_back(point);
// Keep only last 10 minutes of data
int64_t cutoffTime = timestamp - MAX_TIME_MS;
for (auto &entry : seriesMap) {
auto &points = entry.second.points;
auto it = points.begin();
while (it != points.end() && it->timestamp < cutoffTime) {
it = points.erase(it);
}
}
// Update temperature range
minTemp = MIN_TEMP;
maxTemp = MAX_TEMP;
// Update time range - keep 10 minute window
maxTime = timestamp;
minTime = timestamp - MAX_TIME_MS;
// Trigger redraw
gtk_widget_queue_draw(drawingArea);
}
void TemperatureChart::clear()
{
seriesMap.clear();
colorMap.clear();
minTemp = MIN_TEMP;
maxTemp = MAX_TEMP;
minTime = 0;
maxTime = 0;
gtk_widget_queue_draw(drawingArea);
}
std::vector<std::pair<std::string, GdkRGBA>> TemperatureChart::getDeviceColors() const
{
std::vector<std::pair<std::string, GdkRGBA>> result;
for (const auto &entry : colorMap) {
result.push_back({entry.first, entry.second});
}
return result;
}
gboolean TemperatureChart::onTick(gpointer userData)
{
TemperatureChart *self = static_cast<TemperatureChart*>(userData);
gtk_widget_queue_draw(self->drawingArea);
return TRUE;
}
void TemperatureChart::drawChart(GtkDrawingArea *area, cairo_t *cr, int width, int height)
{
// Update theme colors before drawing
updateThemeColors();
// Background with theme color
cairo_set_source_rgb(cr, bgColor.red, bgColor.green, bgColor.blue);
cairo_paint(cr);
if (seriesMap.empty() || maxTime == minTime) {
// Draw placeholder
cairo_set_source_rgb(cr, textColor.red * 0.6, textColor.green * 0.6, textColor.blue * 0.6);
cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL);
cairo_set_font_size(cr, 14);
cairo_move_to(cr, width / 2 - 100, height / 2);
cairo_show_text(cr, "Waiting for data...");
return;
}
// Margins
double marginLeft = 70, marginRight = 20, marginTop = 40, marginBottom = 40;
double plotWidth = width - marginLeft - marginRight;
double plotHeight = height - marginTop - marginBottom;
// Draw grid and axes
cairo_set_source_rgb(cr, gridColor.red, gridColor.green, gridColor.blue);
cairo_set_line_width(cr, 0.5);
// Y axis grid lines (temperature) - every 10 degrees
for (double temp = minTemp; temp <= maxTemp; temp += 10.0) {
double y = marginTop + plotHeight * (1.0 - (temp - minTemp) / (maxTemp - minTemp));
cairo_move_to(cr, marginLeft, y);
cairo_line_to(cr, marginLeft + plotWidth, y);
cairo_stroke(cr);
}
// X axis grid lines (time) - 5 lines for 10-minute window
for (int i = 0; i <= 5; i++) {
double x = marginLeft + (plotWidth * i / 5.0);
cairo_move_to(cr, x, marginTop);
cairo_line_to(cr, x, marginTop + plotHeight);
cairo_stroke(cr);
}
// Draw axes
cairo_set_source_rgb(cr, axisColor.red, axisColor.green, axisColor.blue);
cairo_set_line_width(cr, 2);
cairo_move_to(cr, marginLeft, marginTop);
cairo_line_to(cr, marginLeft, marginTop + plotHeight);
cairo_line_to(cr, marginLeft + plotWidth, marginTop + plotHeight);
cairo_stroke(cr);
// Draw axis labels
cairo_set_source_rgb(cr, textColor.red, textColor.green, textColor.blue);
cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL);
cairo_set_font_size(cr, 10);
// Temperature labels every 10 degrees on Y axis
for (double temp = minTemp; temp <= maxTemp; temp += 10.0) {
double y = marginTop + plotHeight * (1.0 - (temp - minTemp) / (maxTemp - minTemp));
char tempStr[16];
snprintf(tempStr, sizeof(tempStr), "%.0f°C", temp);
// Measure text width for proper right alignment
cairo_text_extents_t extents;
cairo_text_extents(cr, tempStr, &extents);
// Position text right-aligned, 8 pixels before the plot area
double textX = marginLeft - 8 - extents.width;
cairo_move_to(cr, textX, y + 4);
cairo_show_text(cr, tempStr);
}
// Time labels on X axis (5 points for 10-minute window)
for (int i = 0; i <= 5; i++) {
double x = marginLeft + (plotWidth * i / 5.0);
// Calculate time offset in seconds
int secondsOffset = (5 - i) * 120; // 10 minutes / 5 = 2 minutes between points
int minutes = secondsOffset / 60;
char timeStr[16];
snprintf(timeStr, sizeof(timeStr), "-%d m", minutes);
cairo_move_to(cr, x - 20, marginTop + plotHeight + 20);
cairo_show_text(cr, timeStr);
}
// Draw data series (right to left, with 10-minute window)
for (auto &seriesEntry : seriesMap) {
SeriesData &series = seriesEntry.second;
if (series.points.empty()) continue;
// Set series color
cairo_set_source_rgba(cr, series.color.red, series.color.green,
series.color.blue, series.color.alpha);
cairo_set_line_width(cr, 2);
bool firstPoint = true;
for (const DataPoint &point : series.points) {
// Reverse X axis - newer data on the right, older on the left
double x = marginLeft + plotWidth * (1.0 - (double)(maxTime - point.timestamp) / MAX_TIME_MS);
double y = marginTop + plotHeight * (1.0 - (point.temperature - minTemp) / (maxTemp - minTemp));
if (firstPoint) {
cairo_move_to(cr, x, y);
firstPoint = false;
} else {
cairo_line_to(cr, x, y);
}
}
cairo_stroke(cr);
// Draw dots at data points
cairo_set_source_rgba(cr, series.color.red, series.color.green,
series.color.blue, series.color.alpha);
for (const DataPoint &point : series.points) {
double x = marginLeft + plotWidth * (1.0 - (double)(maxTime - point.timestamp) / MAX_TIME_MS);
double y = marginTop + plotHeight * (1.0 - (point.temperature - minTemp) / (maxTemp - minTemp));
cairo_arc(cr, x, y, 3, 0, 2 * M_PI);
cairo_fill(cr);
}
}
// Draw title
cairo_set_source_rgb(cr, textColor.red, textColor.green, textColor.blue);
cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD);
cairo_set_font_size(cr, 16);
cairo_move_to(cr, width / 2 - 150, 25);
cairo_show_text(cr, "NVMe Disk Temperatures (Real-time)");
}
TemperatureChart::NearestPoint TemperatureChart::findNearestDataPoint(double mouseX, double mouseY, int width, int height)
{
NearestPoint result;
result.found = false;
result.distance = std::numeric_limits<double>::max();
if (seriesMap.empty() || maxTime == minTime) {
return result;
}
// Same margins as in drawChart
double marginLeft = 70, marginRight = 20, marginTop = 40, marginBottom = 40;
double plotWidth = width - marginLeft - marginRight;
double plotHeight = height - marginTop - marginBottom;
// Check if mouse is in plot area
if (mouseX < marginLeft || mouseX > marginLeft + plotWidth ||
mouseY < marginTop || mouseY > marginTop + plotHeight) {
return result;
}
for (auto &seriesEntry : seriesMap) {
SeriesData &series = seriesEntry.second;
for (const DataPoint &point : series.points) {
double x = marginLeft + plotWidth * (1.0 - (double)(maxTime - point.timestamp) / MAX_TIME_MS);
double y = marginTop + plotHeight * (1.0 - (point.temperature - minTemp) / (maxTemp - minTemp));
double distance = std::hypot(mouseX - x, mouseY - y);
if (distance < result.distance) {
result.distance = distance;
result.found = true;
result.temperature = point.temperature;
result.timestamp = point.timestamp;
result.seriesName = seriesEntry.first;
}
}
}
return result;
}
void TemperatureChart::showTooltip(double x, double y, const NearestPoint &point)
{
if (!tooltipWindow) {
tooltipWindow = gtk_popover_new();
tooltipLabel = gtk_label_new("");
gtk_widget_set_margin_start(tooltipLabel, 8);
gtk_widget_set_margin_end(tooltipLabel, 8);
gtk_widget_set_margin_top(tooltipLabel, 5);
gtk_widget_set_margin_bottom(tooltipLabel, 5);
gtk_popover_set_child(GTK_POPOVER(tooltipWindow), tooltipLabel);
gtk_popover_set_position(GTK_POPOVER(tooltipWindow), GTK_POS_TOP);
gtk_widget_set_parent(tooltipWindow, drawingArea);
}
// Format the tooltip text with real current time
char tooltipText[256];
// Convert timestamp from milliseconds since epoch to time structure
time_t timeSeconds = point.timestamp / 1000;
struct tm *timeinfo = localtime(&timeSeconds);
char timeStr[32];
strftime(timeStr, sizeof(timeStr), "%H:%M:%S", timeinfo);
snprintf(tooltipText, sizeof(tooltipText),
"%s\n%.1f°C\n%s",
point.seriesName.c_str(),
point.temperature,
timeStr);
gtk_label_set_text(GTK_LABEL(tooltipLabel), tooltipText);
// Position the tooltip
GdkRectangle rect;
rect.x = (int)x;
rect.y = (int)y;
rect.width = 1;
rect.height = 1;
gtk_popover_set_pointing_to(GTK_POPOVER(tooltipWindow), &rect);
gtk_widget_set_visible(tooltipWindow, TRUE);
}
void TemperatureChart::hideTooltip()
{
if (tooltipWindow) {
gtk_widget_unparent(tooltipWindow);
tooltipWindow = nullptr;
tooltipLabel = nullptr;
}
}
void TemperatureChart::onLeave(GtkEventControllerMotion *motion, gpointer userData)
{
TemperatureChart *self = static_cast<TemperatureChart*>(userData);
self->hideTooltip();
}