Skip to content
14 changes: 14 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ FetchContent_Declare(
)
FetchContent_MakeAvailable(inih)

# stb_image
FetchContent_Declare(
stb
GIT_REPOSITORY https://github.com/nothings/stb.git
GIT_TAG master
)
FetchContent_MakeAvailable(stb)


add_library(inih STATIC
${inih_SOURCE_DIR}/ini.c
${inih_SOURCE_DIR}/cpp/INIReader.cpp
Expand All @@ -64,6 +73,7 @@ target_include_directories(inih PUBLIC
${PIPEWIRE_INCLUDE_DIRS}
${SDBUSCPP_INCLUDE_DIRS}
${inih_SOURCE_DIR}/cpp
${stb_SOURCE_DIR}
)

# yaml-cpp
Expand Down Expand Up @@ -98,12 +108,16 @@ add_executable(hyprwat
src/frames/input.cpp
src/frames/text.cpp
src/frames/custom.cpp
src/frames/images.cpp
src/flows/simple_flows.cpp
src/flows/wifi_flow.cpp
src/flows/audio_flow.cpp
src/flows/custom_flow.cpp
src/flows/wallpaper_flow.cpp
src/net/network_manager.cpp
src/audio/audio.cpp
src/wallpaper/thumbnail.cpp
src/wallpaper/wallpaper.cpp
${IMGUI_SOURCES}
${WAYLAND_PROTOCOLS}
)
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ run-audio: debug
run-custom: debug
./build/debug/hyprwat --custom examples/custom_menu/main.yaml

run-wallpaper: debug
./build/debug/hyprwat --wallpaper ~/.local/share/wallpapers

reset-wifi:
sudo nmcli device disconnect wlan0
sudo nmcli device connect wlan0
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ hyprwat creates a popup menu at your cursor position where you can select from a
- **Audio selector**: Built-in support for selecting audio input/output devices (via Pipewire)
- **Custom menus**: Define your own menus using simple YAML configuration files
- **Pre-selection support**: Mark items as initially selected
- **Theming**: Customize the appearance with a configuration file
- **Wallpaper selection**: Easily select a wallpaper from a directory of images (hyprpaper only)

## Installation

Expand Down Expand Up @@ -52,6 +54,7 @@ If no arguments are provided, hyprwat will read from stdin, expecting one item p
- `--audio`: Show audio input/output device selector (requires pipewire)
- `--wifi`: Show WiFi network selection
- `--custom <file>`: Load a custom menu from a YAML configuration file
- `--wallpaper <dir>`: Select a wallpaper from the specified directory (for hyprpaper)


### Examples
Expand Down Expand Up @@ -122,6 +125,9 @@ hyprwat --audio
# Custom menu defined in yaml config files
hyprwat --custom ~/.config/hyprwat/menus/powermenu.yaml

# Wallpaper selection from a directory
hyprwat --wallpaper ~/.local/share/wallpapers

```
See the [examples](examples) directory for more.

Expand Down
51 changes: 51 additions & 0 deletions src/flows/wallpaper_flow.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#include "wallpaper_flow.hpp"

// wallpaper selection flow
// logicalWidth and logicalHeight are the size of the display in logical pixels
WallpaperFlow::WallpaperFlow(hyprland::Control& hyprctl,
const std::string& dir,
const int logicalWidth,
const int logicalHeight)
: wallpaperManager(dir), hyprctl(hyprctl) {

imageList = std::make_unique<ImageList>(logicalWidth, logicalHeight);
}

WallpaperFlow::~WallpaperFlow() = default;

Frame* WallpaperFlow::getCurrentFrame() {
if (!loadingStarted) {
// generate thumbnails in background
loadingThread = std::thread([this]() {
wallpaperManager.loadWallpapers();
const auto& wallpapers = wallpaperManager.getWallpapers();
imageList->addImages(wallpapers);
});
loadingStarted = true;
}

return imageList.get();
}

bool WallpaperFlow::handleResult(const FrameResult& result) {
if (result.action == FrameResult::Action::SUBMIT) {
finalResult = result.value;
done = true;
hyprctl.setWallpaper(finalResult);
} else if (result.action == FrameResult::Action::CANCEL) {
done = true;
}

if (done) {
if (loadingThread.joinable()) {
loadingThread.join();
}
return false;
}

return true;
}

bool WallpaperFlow::isDone() const { return done; }

std::string WallpaperFlow::getResult() const { return finalResult; }
28 changes: 28 additions & 0 deletions src/flows/wallpaper_flow.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#pragma once

#include "../frames/images.hpp"
#include "../hyprland/ipc.hpp"
#include "flow.hpp"

class WallpaperFlow : public Flow {
public:
WallpaperFlow(hyprland::Control& hyprctl,
const std::string& wallpaperDir,
const int logicalWidth,
const int logicalHeight);
~WallpaperFlow();

Frame* getCurrentFrame() override;
bool handleResult(const FrameResult& result) override;
bool isDone() const override;
std::string getResult() const override;

private:
WallpaperManager wallpaperManager;
hyprland::Control& hyprctl;
std::unique_ptr<ImageList> imageList;
std::string finalResult;
std::thread loadingThread;
std::atomic<bool> loadingStarted{false};
bool done = false;
};
199 changes: 199 additions & 0 deletions src/frames/images.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#include "images.hpp"
#include "imgui.h"

// #define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>
// #define STB_IMAGE_RESIZE_IMPLEMENTATION
#include <stb_image_resize2.h>
// #define STB_IMAGE_WRITE_IMPLEMENTATION
#include <stb_image_write.h>

ImageList::ImageList(const int logicalWidth, const int logicalHeight)
: Frame(), wallpapers(), logicalWidth(logicalWidth), logicalHeight(logicalHeight) {}

void ImageList::addImages(const std::vector<Wallpaper>& newWallpapers) {
pendingWallpapers.insert(pendingWallpapers.end(), newWallpapers.begin(), newWallpapers.end());
}

// https://github.com/ocornut/imgui/wiki/Image-Loading-and-Displaying-Examples#example-for-opengl-users
FrameResult ImageList::render() {

// process any new wallpapers to load their textures on the main thread
processPendingWallpapers();

if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow)) {
navigate(-1);
}
if (ImGui::IsKeyPressed(ImGuiKey_RightArrow)) {
navigate(1);
}
if (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_Space)) {
if (selectedIndex >= 0 && selectedIndex < wallpapers.size()) {
return FrameResult::Submit(wallpapers[selectedIndex].path);
}
}
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
return FrameResult::Cancel();
}

ImGuiIO& io = ImGui::GetIO();
Vec2 viewportSize = getSize();

float width = viewportSize.x;
float height = viewportSize.y;

// window padding
float edge_padding = 20.0f;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(edge_padding, edge_padding));

ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(ImVec2(width, height), ImGuiCond_Always);

ImGui::Begin("Wallpapers",
nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoResize);

// image display area
ImVec2 content_region = ImGui::GetContentRegionAvail();
float image_area_height = content_region.y;

// image size
float image_height = 225; // image_area_height - 40.0f; // padding
float image_width = 400; // image_height * 0.75f;
float spacing = 20.0f;
float total_width_per_image = image_width + spacing;

// smooth scroll to selected image
float target_scroll = selectedIndex * total_width_per_image - (content_region.x - image_width) * 0.5f;
scrollOffset += (target_scroll - scrollOffset) * 0.15f; // smooth interpolation

std::lock_guard<std::mutex> lock(wallpapersMutex);

if (textures.empty()) {
ImGui::SetWindowFontScale(2.0f);

const char* text = "Generating thumbnails...";
ImVec2 textSize = ImGui::CalcTextSize(text);
ImVec2 windowSize = ImGui::GetContentRegionAvail();
ImGui::SetCursorPosX((windowSize.x - textSize.x) * 0.5f);
ImGui::SetCursorPosY((windowSize.y - textSize.y) * 0.5f);
ImGui::Text("%s", text);

ImGui::SetWindowFontScale(1.0f);
} else {

ImGui::BeginChild("ScrollRegion",
ImVec2(0, image_area_height),
false,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);

ImGui::SetScrollX(scrollOffset);

// render images horizontally
for (int i = 0; i < textures.size(); i++) {
if (i > 0)
ImGui::SameLine();

ImGui::BeginGroup();

// highlight selected image
bool is_selected = (i == selectedIndex);

if (is_selected) {
ImVec2 p_min = ImGui::GetCursorScreenPos();
ImVec2 p_max = ImVec2(p_min.x + image_width, p_min.y + image_height);
ImU32 color = ImGui::GetColorU32(hoverColor);
// draw on foreground layer to avoid child clipping
ImGui::GetForegroundDrawList()->AddRect(p_min, p_max, color, 0.0f, 0, 4.0f);
}

// make images clickable
ImGui::PushID(i);
ImGui::Image((void*)(intptr_t)textures[i], ImVec2(image_width, image_height));
ImGui::PopID();

ImGui::EndGroup();

// add spacing
if (i < textures.size() - 1) {
ImGui::SameLine(0.0f, spacing);
}
}

ImGui::EndChild();
}

ImGui::End();

ImGui::PopStyleVar();
return FrameResult::Continue();
}

void ImageList::processPendingWallpapers() {
std::lock_guard<std::mutex> lock(wallpapersMutex);

for (const auto& wallpaper : pendingWallpapers) {
GLuint texture = LoadTextureFromFile(wallpaper.thumbnailPath.c_str());
if (texture != 0) {
textures.push_back(texture);
} else {
std::cerr << "Failed to load texture: " << wallpaper.thumbnailPath << std::endl;
textures.push_back(0);
}
wallpapers.push_back(wallpaper);
}

pendingWallpapers.clear();
}

void ImageList::navigate(int direction) {
if (textures.empty())
return;
selectedIndex += direction;
if (selectedIndex < 0)
selectedIndex = 0;
if (selectedIndex >= textures.size())
selectedIndex = textures.size() - 1;
}

Vec2 ImageList::getSize() {
// 2/3 width, 1/2 height
// return Vec2{, (float)logicalHeight / 0.5f};
// float w = (float)logicalWidth * 0.666f;
float w = (float)logicalWidth * 0.8;
float edge_padding = 20.0f; // padding we want on all sides
float content_height = 225.0f; // height of the image area
// add padding to width and height to account for the space we need
return Vec2{w + (edge_padding * 2), content_height + (edge_padding * 2)};
}

// load image and create an OpenGL texture
GLuint ImageList::LoadTextureFromFile(const char* filename) {

int width, height, channels;
unsigned char* data = stbi_load(filename, &width, &height, &channels, 4);

if (data == nullptr) {
return 0;
}

// create OpenGL texture
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

// upload pixels to texture
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);

stbi_image_free(data);

return texture;
}

void ImageList::applyTheme(const Config& config) { hoverColor = config.getColor("theme", "hover_color", "#3366B366"); }
32 changes: 32 additions & 0 deletions src/frames/images.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#pragma once

#include "../ui.hpp"
#include "../wallpaper/wallpaper.hpp"
#include <GL/gl.h>

class ImageList : public Frame {
public:
ImageList(const int logicalWidth, const int logicalHeight);
virtual FrameResult render() override;
virtual Vec2 getSize() override;
virtual void applyTheme(const Config& config) override;
virtual bool shouldRepositionOnResize() const override { return false; }
virtual bool shouldPositionAtCursor() const override { return false; }

void addImages(const std::vector<Wallpaper>& wallpapers);

private:
int selectedIndex = 0;
float scrollOffset = 0.0f;
int logicalWidth;
int logicalHeight;
std::vector<GLuint> textures;
std::vector<Wallpaper> wallpapers;
std::vector<Wallpaper> pendingWallpapers;
std::mutex wallpapersMutex;
ImVec4 hoverColor = ImVec4(0.2f, 0.4f, 0.7f, 1.0f);

void processPendingWallpapers();
GLuint LoadTextureFromFile(const char* filename);
void navigate(int direction);
};
1 change: 0 additions & 1 deletion src/frames/text.hpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#pragma once

#include "../flows/flow.hpp"
#include "../imgui/imgui.h"
#include "../ui.hpp"
#include <string>
Expand Down
Loading
Loading