Add Qt5/KDE based "application host" (systray icon, native notifications)

This commit is contained in:
Daniel Schulte 2020-12-21 00:00:01 +01:00
parent b8bc284c8d
commit 00281a1a0d
7 changed files with 365 additions and 19 deletions

View File

@ -1,4 +1,7 @@
// SPDX-License-Identifier: MIT
#include "application.h"
#define _X_OPEN_SOURCE
#include "tui.h"
@ -35,6 +38,8 @@ size_t title_offset = 0;
bool any_title_in_next_half = false;
bool clear_channels_on_change = false;
static application_host *host = nullptr;
static termpaint_attr* get_attr(const AttributeSetType type, const bool highlight=false)
{
return highlight ? attributes[type].highlight : attributes[type].normal;
@ -368,18 +373,25 @@ void action_refresh_channel() {
const int new_videos = fetch_videos_for_channel(channels.at(selected_channel));
if(new_videos == 0 || ch.is_virtual)
return;
if(new_videos == 1 && !notify_channel_new_video_command.empty()) {
run_command(notify_channel_new_video_command, {
{"{{channelName}}", ch.name},
{"{{videoTitle}}", videos[ch.id].front().title},
});
} else if(notify_channel_new_videos_command.size()) {
run_command(notify_channel_new_videos_command, {
{"{{channelName}}", ch.name},
{"{{newVideos}}", std::to_string(new_videos)}
});
if(new_videos == 1) {
if(host && host->notify_channel_single_video) {
host->notify_channel_single_video(ch.name, videos[ch.id].front().title);
} else if(!notify_channel_new_video_command.empty()) {
run_command(notify_channel_new_video_command, {
{"{{channelName}}", ch.name},
{"{{videoTitle}}", videos[ch.id].front().title},
});
}
} else {
if(host && host->notify_channel_multiple_videos) {
host->notify_channel_multiple_videos(ch.name, new_videos);
} else if(notify_channel_new_videos_command.size()) {
run_command(notify_channel_new_videos_command, {
{"{{channelName}}", ch.name},
{"{{newVideos}}", std::to_string(new_videos)}
});
}
}
}
void action_refresh_all_channels(bool ask=true) {
@ -395,11 +407,15 @@ void action_refresh_all_channels(bool ask=true) {
if(count)
updated_channels++;
}
if(updated_channels && new_videos && !notify_channels_new_videos_command.empty()) {
run_command(notify_channels_new_videos_command, {
{"{{updatedChannels}}", std::to_string(updated_channels)},
{"{{newVideos}}", std::to_string(new_videos)}
});
if(updated_channels && new_videos) {
if(host && host->notify_channels_multiple_videos) {
host->notify_channels_multiple_videos(updated_channels, new_videos);
} else if(!notify_channels_new_videos_command.empty()) {
run_command(notify_channels_new_videos_command, {
{"{{updatedChannels}}", std::to_string(updated_channels)},
{"{{newVideos}}", std::to_string(new_videos)}
});
}
}
}
@ -645,6 +661,10 @@ static void run()
bool draw = true;
do {
if(host && host->quit && host->quit()) {
break;
}
if(draw) {
Channel &channel = channels.at(selected_channel);
termpaint_surface_clear(surface, TERMPAINT_DEFAULT_COLOR, TERMPAINT_DEFAULT_COLOR);
@ -654,7 +674,7 @@ static void run()
}
draw = true;
auto event = tp_wait_for_event(1000);
auto event = tp_wait_for_event(500);
if(!event)
abort();
@ -683,9 +703,10 @@ void run_standalone()
tp_shutdown();
}
void run_embedded(int pty_fd)
void run_embedded(int pty_fd, application_host *_host)
{
tp_init_from_fd(pty_fd);
host = _host;
run();
tp_shutdown();
}

View File

@ -1,4 +1,14 @@
#pragma once
#include <functional>
#include <string>
struct application_host {
std::function<bool()> quit = nullptr;
std::function<void(const std::string &channel, const std::string &title)> notify_channel_single_video = nullptr;
std::function<void(const std::string &channel, const int count)> notify_channel_multiple_videos = nullptr;
std::function<void(const int channels, const int count)> notify_channels_multiple_videos = nullptr;
};
void run_standalone();
void run_embedded(int pty_fd);
void run_embedded(int pty_fd, application_host *host);

BIN
icons/icon_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

96
icons/icon_0.svg Normal file
View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="icon_0.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="3.959798"
inkscape:cx="96.726796"
inkscape:cy="133.29632"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
units="px"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1135"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#808080;fill-opacity:1;stroke:#7f7f7f;stroke-width:2.11666667;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;stroke-linejoin:miter;opacity:0.5"
id="rect889"
width="63.500004"
height="46.302082"
x="2.1166673"
y="10.715626" />
<rect
style="fill:#1a1a1a;fill-opacity:1;stroke-width:0.330729"
id="rect10"
width="63.5"
height="42.333332"
x="2.1166673"
y="14.684376" />
<rect
style="fill:#cccccc;fill-opacity:1;stroke-width:0.0980494"
id="rect10-3"
width="59.53125"
height="3.96875"
x="2.1166673"
y="10.715626" />
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:29.1042px;line-height:1.25;font-family:sans-serif;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583"
x="4.7997642"
y="43.432621"
id="text856"><tspan
sodipodi:role="line"
id="tspan854"
x="4.7997642"
y="43.432621"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:29.1042px;font-family:Hack;-inkscape-font-specification:Hack;fill:#00ff00;stroke-width:0.264583">&gt;yt</tspan></text>
<rect
style="fill:#666666;fill-opacity:1;stroke-width:0.0253162"
id="rect869"
width="3.9687486"
height="3.96875"
x="61.647919"
y="10.715626" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -30,3 +30,23 @@ tui_files = [
'yttui.cpp',
]
executable('yttui', tui_files, link_with: [application], install: true)
qt5 = import('qt5')
qt5_dep = dependency('qt5', modules: ['Core', 'Gui', 'Widgets'], required: false)
if qt5_dep.found()
kf5service_dep = dependency('KF5Service', required: false)
kf5core_dep = dependency('KF5CoreAddons', required: false)
kf5parts_dep = dependency('KF5Parts', required: false)
cppc = meson.get_compiler('cpp')
libutil_dep = cppc.find_library('util')
moc_files = qt5.preprocess(moc_sources: ['yttui-qt5.cpp'], qresources: ['yttui-qt5.qrc'])
executable('yttui-qt5',
['yttui-qt5.cpp', moc_files],
link_with: [application],
dependencies: [libutil_dep, qt5_dep, kf5service_dep, kf5core_dep, kf5parts_dep],
install: true
)
endif

194
yttui-qt5.cpp Normal file
View File

@ -0,0 +1,194 @@
#include <QApplication>
#include <QIcon>
#include <QMainWindow>
#include <QMenu>
#include <QMetaEnum>
#include <QMetaObject>
#include <QMouseEvent>
#include <QString>
#include <QSystemTrayIcon>
#include <QThread>
#include <KParts/Part>
#include <KParts/ReadOnlyPart>
#include <KPluginFactory>
#include <KPluginLoader>
#include <KService>
#include <kde_terminal_interface.h>
#include <unistd.h>
#include <pty.h>
#include "application.h"
application_host *host = nullptr;
class AppThread: public QThread
{
Q_OBJECT
int app_fd;
public:
AppThread(int fd): QThread(), app_fd(fd){}
~AppThread() = default;
friend bool app_quit(void*);
protected:
void run() override
{
run_embedded(app_fd, host);
}
};
class RightClickFilter: public QObject
{
Q_OBJECT
protected:
bool eventFilter(QObject *obj, QEvent *event)
{
if((event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonRelease)) {
QMouseEvent *mouseEvent = static_cast<QMouseEvent*>(event);
if(mouseEvent->buttons() & Qt::RightButton) {
return true;
}
}
else if(event->type() == QEvent::ContextMenu) {
return true;
}
return QObject::eventFilter(obj, event);
}
};
QObject* find_obj_by_classname(QObject *in, const char* name)
{
if(strcmp(name, in->metaObject()->className()) == 0)
return in;
for(QObject *child: in->children())
{
QObject *maybe = find_obj_by_classname(child, name);
if(maybe)
return maybe;
}
return nullptr;
}
class ApplicationWindow: public QMainWindow
{
Q_OBJECT
QIcon icon;
QSystemTrayIcon *systray = nullptr;
KParts::ReadOnlyPart *terminal = nullptr;
QWidget *konsole = nullptr;
public:
ApplicationWindow(int fd);
void showMessage(const QString &title, const QString &message);
// QWidget interface
protected:
void closeEvent(QCloseEvent *event) override;
};
ApplicationWindow::ApplicationWindow(int fd): icon(":/icons/icon_0.png"), systray(new QSystemTrayIcon(icon, this))
{
setWindowTitle("yttui-qt5");
setWindowIcon(icon);
KService::Ptr service = KService::serviceByDesktopName(QStringLiteral("konsolepart"));
Q_ASSERT(service);
KPluginFactory* factory = KPluginLoader(service->library()).factory();
Q_ASSERT(factory);
terminal = factory->create<KParts::ReadOnlyPart>(this);
if(!QMetaObject::invokeMethod(terminal, "openTeletype", Qt::AutoConnection, Q_ARG(int, fd), Q_ARG(bool, false))) {
fputs("Failed to set KonsolePart PTY\n", stderr);
abort();
}
konsole = terminal->widget();
setCentralWidget(konsole);
QObject *td = find_obj_by_classname(konsole, "Konsole::TerminalDisplay");
if(td) {
td->installEventFilter(new RightClickFilter());
}
QMenu *systrayContextMenu = new QMenu(this);
systrayContextMenu->addAction(QIcon::fromTheme("application-exit"), "Exit", QApplication::instance(), &QApplication::quit);
systray->setContextMenu(systrayContextMenu);
connect(systray, &QSystemTrayIcon::activated, [this](QSystemTrayIcon::ActivationReason reason) {
if(reason == QSystemTrayIcon::Trigger) {
if(isVisible()) {
hide();
} else {
show();
}
}
});
systray->show();
}
void ApplicationWindow::showMessage(const QString &title, const QString &message)
{
systray->showMessage(title, message, icon);
}
void ApplicationWindow::closeEvent(QCloseEvent *event)
{
QMainWindow::closeEvent(event);
systray->hide();
}
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
int term_fd = -1, app_fd = -1;
if(openpty(&term_fd, &app_fd, 0, 0, 0)) {
perror("openpty");
}
ApplicationWindow window(term_fd);
window.show();
bool app_quit = false;
AppThread appthread(app_fd);
QObject::connect(&appthread, &AppThread::finished, [&] {
window.close();
});
QObject::connect(&app, &QApplication::aboutToQuit, [&] {
app_quit = true;
appthread.wait();
});
host = new application_host;
host->quit = [&]{ return app_quit; };
host->notify_channel_single_video = [&](const std::string &channel, const std::string &title) {
window.showMessage(QStringLiteral("New video from %1").arg(QString::fromStdString(channel)), QString::fromStdString(title));
};
host->notify_channel_multiple_videos = [&](const std::string &channel, const int videos) {
window.showMessage(QStringLiteral("New videos from %1").arg(QString::fromStdString(channel)),
QStringLiteral("There are %1 new videos.").arg(QString::number(videos)));
};
host->notify_channels_multiple_videos = [&](const int channels, const int videos) {
window.showMessage("New videos from multiple channels",
QStringLiteral("There are %2 new videos from %1 channels.").arg(QString::number(channels), QString::number(videos)));
};
appthread.start();
int rc = app.exec();
close(term_fd);
close(app_fd);
delete host;
return rc;
}
#include "yttui-qt5.moc"

5
yttui-qt5.qrc Normal file
View File

@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/">
<file>icons/icon_0.png</file>
</qresource>
</RCC>