This commit is contained in:
xsghetti 2024-04-11 00:21:35 -04:00
parent 1f8cb3c145
commit 610604e80f
253 changed files with 27055 additions and 44 deletions

View file

@ -0,0 +1,345 @@
const { Gdk, Gio, GLib, Gtk } = imports.gi;
import GtkSource from "gi://GtkSource?version=3.0";
import App from 'resource:///com/github/Aylur/ags/app.js';
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { Box, Button, Label, Icon, Scrollable } = Widget;
const { execAsync, exec } = Utils;
import { MaterialIcon } from '../../.commonwidgets/materialicon.js';
import md2pango from '../../.miscutils/md2pango.js';
import { darkMode } from "../../.miscutils/system.js";
const LATEX_DIR = `${GLib.get_user_cache_dir()}/ags/media/latex`;
const CUSTOM_SOURCEVIEW_SCHEME_PATH = `${App.configDir}/assets/themes/sourceviewtheme${darkMode.value ? '' : '-light'}.xml`;
const CUSTOM_SCHEME_ID = `custom${darkMode.value ? '' : '-light'}`;
const USERNAME = GLib.get_user_name();
/////////////////////// Custom source view colorscheme /////////////////////////
function loadCustomColorScheme(filePath) {
// Read the XML file content
const file = Gio.File.new_for_path(filePath);
const [success, contents] = file.load_contents(null);
if (!success) {
logError('Failed to load the XML file.');
return;
}
// Parse the XML content and set the Style Scheme
const schemeManager = GtkSource.StyleSchemeManager.get_default();
schemeManager.append_search_path(file.get_parent().get_path());
}
loadCustomColorScheme(CUSTOM_SOURCEVIEW_SCHEME_PATH);
//////////////////////////////////////////////////////////////////////////////
function substituteLang(str) {
const subs = [
{ from: 'javascript', to: 'js' },
{ from: 'bash', to: 'sh' },
];
for (const { from, to } of subs) {
if (from === str)
return to;
}
return str;
}
const HighlightedCode = (content, lang) => {
const buffer = new GtkSource.Buffer();
const sourceView = new GtkSource.View({
buffer: buffer,
wrap_mode: Gtk.WrapMode.NONE
});
const langManager = GtkSource.LanguageManager.get_default();
let displayLang = langManager.get_language(substituteLang(lang)); // Set your preferred language
if (displayLang) {
buffer.set_language(displayLang);
}
const schemeManager = GtkSource.StyleSchemeManager.get_default();
buffer.set_style_scheme(schemeManager.get_scheme(CUSTOM_SCHEME_ID));
buffer.set_text(content, -1);
return sourceView;
}
const TextBlock = (content = '') => Label({
hpack: 'fill',
className: 'txt sidebar-chat-txtblock sidebar-chat-txt',
useMarkup: true,
xalign: 0,
wrap: true,
selectable: true,
label: content,
});
Utils.execAsync(['bash', '-c', `rm ${LATEX_DIR}/*`])
.then(() => Utils.execAsync(['bash', '-c', `mkdir -p ${LATEX_DIR}`]))
.catch(() => { });
const Latex = (content = '') => {
const latexViewArea = Box({
// vscroll: 'never',
// hscroll: 'automatic',
// homogeneous: true,
attribute: {
render: async (self, text) => {
if (text.length == 0) return;
const styleContext = self.get_style_context();
const fontSize = styleContext.get_property('font-size', Gtk.StateFlags.NORMAL);
const timeSinceEpoch = Date.now();
const fileName = `${timeSinceEpoch}.tex`;
const outFileName = `${timeSinceEpoch}-symbolic.svg`;
const outIconName = `${timeSinceEpoch}-symbolic`;
const scriptFileName = `${timeSinceEpoch}-render.sh`;
const filePath = `${LATEX_DIR}/${fileName}`;
const outFilePath = `${LATEX_DIR}/${outFileName}`;
const scriptFilePath = `${LATEX_DIR}/${scriptFileName}`;
Utils.writeFile(text, filePath).catch(print);
// Since MicroTex doesn't support file path input properly, we gotta cat it
// And escaping such a command is a fucking pain so I decided to just generate a script
// Note: MicroTex doesn't support `&=`
// You can add this line in the middle for debugging: echo "$text" > ${filePath}.tmp
const renderScript = `#!/usr/bin/env bash
text=$(cat ${filePath} | sed 's/$/ \\\\\\\\/g' | sed 's/&=/=/g')
LaTeX -headless -input="$text" -output=${outFilePath} -textsize=${fontSize * 1.1} -padding=0 -maxwidth=${latexViewArea.get_allocated_width() * 0.85}
sed -i 's/fill="rgb(0%, 0%, 0%)"/style="fill:#000000"/g' ${outFilePath}
sed -i 's/stroke="rgb(0%, 0%, 0%)"/stroke="${darkMode.value ? '#ffffff' : '#000000'}"/g' ${outFilePath}
`;
Utils.writeFile(renderScript, scriptFilePath).catch(print);
Utils.exec(`chmod a+x ${scriptFilePath}`)
Utils.timeout(100, () => {
Utils.exec(`bash ${scriptFilePath}`);
Gtk.IconTheme.get_default().append_search_path(LATEX_DIR);
self.child?.destroy();
self.child = Gtk.Image.new_from_icon_name(outIconName, 0);
})
}
},
setup: (self) => self.attribute.render(self, content).catch(print),
});
const wholeThing = Box({
className: 'sidebar-chat-latex',
homogeneous: true,
attribute: {
'updateText': (text) => {
latexViewArea.attribute.render(latexViewArea, text).catch(print);
}
},
children: [Scrollable({
vscroll: 'never',
hscroll: 'automatic',
child: latexViewArea
})]
})
return wholeThing;
}
const CodeBlock = (content = '', lang = 'txt') => {
if (lang == 'tex' || lang == 'latex') {
return Latex(content);
}
const topBar = Box({
className: 'sidebar-chat-codeblock-topbar',
children: [
Label({
label: lang,
className: 'sidebar-chat-codeblock-topbar-txt',
}),
Box({
hexpand: true,
}),
Button({
className: 'sidebar-chat-codeblock-topbar-btn',
child: Box({
className: 'spacing-h-5',
children: [
MaterialIcon('content_copy', 'small'),
Label({
label: 'Copy',
})
]
}),
onClicked: (self) => {
const buffer = sourceView.get_buffer();
const copyContent = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), false); // TODO: fix this
execAsync([`wl-copy`, `${copyContent}`]).catch(print);
},
}),
]
})
// Source view
const sourceView = HighlightedCode(content, lang);
const codeBlock = Box({
attribute: {
'updateText': (text) => {
sourceView.get_buffer().set_text(text, -1);
}
},
className: 'sidebar-chat-codeblock',
vertical: true,
children: [
topBar,
Box({
className: 'sidebar-chat-codeblock-code',
homogeneous: true,
children: [Scrollable({
vscroll: 'never',
hscroll: 'automatic',
child: sourceView,
})],
})
]
})
// const schemeIds = styleManager.get_scheme_ids();
// print("Available Style Schemes:");
// for (let i = 0; i < schemeIds.length; i++) {
// print(schemeIds[i]);
// }
return codeBlock;
}
const Divider = () => Box({
className: 'sidebar-chat-divider',
})
const MessageContent = (content) => {
const contentBox = Box({
vertical: true,
attribute: {
'fullUpdate': (self, content, useCursor = false) => {
// Clear and add first text widget
const children = contentBox.get_children();
for (let i = 0; i < children.length; i++) {
const child = children[i];
child.destroy();
}
contentBox.add(TextBlock())
// Loop lines. Put normal text in markdown parser
// and put code into code highlighter (TODO)
let lines = content.split('\n');
let lastProcessed = 0;
let inCode = false;
for (const [index, line] of lines.entries()) {
// Code blocks
const codeBlockRegex = /^\s*```([a-zA-Z0-9]+)?\n?/;
if (codeBlockRegex.test(line)) {
const kids = self.get_children();
const lastLabel = kids[kids.length - 1];
const blockContent = lines.slice(lastProcessed, index).join('\n');
if (!inCode) {
lastLabel.label = md2pango(blockContent);
contentBox.add(CodeBlock('', codeBlockRegex.exec(line)[1]));
}
else {
lastLabel.attribute.updateText(blockContent);
contentBox.add(TextBlock());
}
lastProcessed = index + 1;
inCode = !inCode;
}
// Breaks
const dividerRegex = /^\s*---/;
if (!inCode && dividerRegex.test(line)) {
const kids = self.get_children();
const lastLabel = kids[kids.length - 1];
const blockContent = lines.slice(lastProcessed, index).join('\n');
lastLabel.label = md2pango(blockContent);
contentBox.add(Divider());
contentBox.add(TextBlock());
lastProcessed = index + 1;
}
}
if (lastProcessed < lines.length) {
const kids = self.get_children();
const lastLabel = kids[kids.length - 1];
let blockContent = lines.slice(lastProcessed, lines.length).join('\n');
if (!inCode)
lastLabel.label = `${md2pango(blockContent)}${useCursor ? userOptions.ai.writingCursor : ''}`;
else
lastLabel.attribute.updateText(blockContent);
}
// Debug: plain text
// contentBox.add(Label({
// hpack: 'fill',
// className: 'txt sidebar-chat-txtblock sidebar-chat-txt',
// useMarkup: false,
// xalign: 0,
// wrap: true,
// selectable: true,
// label: '------------------------------\n' + md2pango(content),
// }))
contentBox.show_all();
}
}
});
contentBox.attribute.fullUpdate(contentBox, content, false);
return contentBox;
}
export const ChatMessage = (message, modelName = 'Model') => {
const messageContentBox = MessageContent(message.content);
const thisMessage = Box({
className: 'sidebar-chat-message',
homogeneous: true,
children: [
Box({
vertical: true,
children: [
Label({
hpack: 'start',
xalign: 0,
className: `txt txt-bold sidebar-chat-name sidebar-chat-name-${message.role == 'user' ? 'user' : 'bot'}`,
wrap: true,
useMarkup: true,
label: (message.role == 'user' ? USERNAME : modelName),
}),
messageContentBox,
],
setup: (self) => self
.hook(message, (self, isThinking) => {
messageContentBox.toggleClassName('thinking', message.thinking);
}, 'notify::thinking')
.hook(message, (self) => { // Message update
messageContentBox.attribute.fullUpdate(messageContentBox, message.content, message.role != 'user');
}, 'notify::content')
.hook(message, (label, isDone) => { // Remove the cursor
messageContentBox.attribute.fullUpdate(messageContentBox, message.content, false);
}, 'notify::done')
,
})
]
});
return thisMessage;
}
export const SystemMessage = (content, commandName, scrolledWindow) => {
const messageContentBox = MessageContent(content);
const thisMessage = Box({
className: 'sidebar-chat-message',
children: [
Box({
vertical: true,
children: [
Label({
xalign: 0,
hpack: 'start',
className: 'txt txt-bold sidebar-chat-name sidebar-chat-name-system',
wrap: true,
label: `System • ${commandName}`,
}),
messageContentBox,
],
})
],
});
return thisMessage;
}

View file

@ -0,0 +1,442 @@
// TODO: execAsync(['identify', '-format', '{"w":%w,"h":%h}', imagePath])
// to detect img dimensions
const { Gdk, GdkPixbuf, Gio, GLib, Gtk } = imports.gi;
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { Box, Button, Label, Overlay, Revealer, Scrollable, Stack } = Widget;
const { execAsync, exec } = Utils;
import { fileExists } from '../../.miscutils/files.js';
import { MaterialIcon } from '../../.commonwidgets/materialicon.js';
import { MarginRevealer } from '../../.widgethacks/advancedrevealers.js';
import { setupCursorHover, setupCursorHoverInfo } from '../../.widgetutils/cursorhover.js';
import BooruService from '../../../services/booru.js';
import { chatEntry } from '../apiwidgets.js';
import { ConfigToggle } from '../../.commonwidgets/configwidgets.js';
const Grid = Widget.subclass(Gtk.Grid, "AgsGrid");
async function getImageViewerApp(preferredApp) {
Utils.execAsync(['bash', '-c', `command -v ${preferredApp}`])
.then((output) => {
if (output != '') return preferredApp;
else return 'xdg-open';
});
}
const IMAGE_REVEAL_DELAY = 13; // Some wait for inits n other weird stuff
const IMAGE_VIEWER_APP = getImageViewerApp(userOptions.apps.imageViewer); // Gnome's image viewer cuz very comfortable zooming
const USER_CACHE_DIR = GLib.get_user_cache_dir();
// Create cache folder and clear pics from previous session
Utils.exec(`bash -c 'mkdir -p ${USER_CACHE_DIR}/ags/media/waifus'`);
Utils.exec(`bash -c 'rm ${USER_CACHE_DIR}/ags/media/waifus/*'`);
const CommandButton = (command) => Button({
className: 'sidebar-chat-chip sidebar-chat-chip-action txt txt-small',
onClicked: () => sendMessage(command),
setup: setupCursorHover,
label: command,
});
export const booruTabIcon = Box({
hpack: 'center',
homogeneous: true,
children: [
MaterialIcon('gallery_thumbnail', 'norm'),
]
});
const BooruInfo = () => {
const booruLogo = Label({
hpack: 'center',
className: 'sidebar-chat-welcome-logo',
label: 'gallery_thumbnail',
})
return Box({
vertical: true,
vexpand: true,
className: 'spacing-v-15',
children: [
booruLogo,
Label({
className: 'txt txt-title-small sidebar-chat-welcome-txt',
wrap: true,
justify: Gtk.Justification.CENTER,
label: 'Anime booru',
}),
Box({
className: 'spacing-h-5',
hpack: 'center',
children: [
Label({
className: 'txt-smallie txt-subtext',
wrap: true,
justify: Gtk.Justification.CENTER,
label: 'Powered by yande.re',
}),
Button({
className: 'txt-subtext txt-norm icon-material',
label: 'info',
tooltipText: 'An image booru. May contain NSFW content.\nWatch your back.\n\nDisclaimer: Not affiliated with the provider\nnor responsible for any of its content.',
setup: setupCursorHoverInfo,
}),
]
}),
]
});
}
export const BooruSettings = () => MarginRevealer({
transition: 'slide_down',
revealChild: true,
child: Box({
vertical: true,
className: 'sidebar-chat-settings',
children: [
Box({
vertical: true,
hpack: 'fill',
className: 'sidebar-chat-settings-toggles',
children: [
ConfigToggle({
icon: 'menstrual_health',
name: 'Lewds',
desc: `Shows naughty stuff when enabled.\nYa like those? Add this to user_options.js:
'sidebar': {
'imageAllowNsfw': true,
},`,
initValue: BooruService.nsfw,
onChange: (self, newValue) => {
BooruService.nsfw = newValue;
},
}),
]
})
]
})
});
const booruWelcome = Box({
vexpand: true,
homogeneous: true,
child: Box({
className: 'spacing-v-15',
vpack: 'center',
vertical: true,
children: [
BooruInfo(),
BooruSettings(),
]
})
});
const BooruPage = (taglist) => {
const PageState = (icon, name) => Box({
className: 'spacing-h-5 txt',
children: [
Label({
className: 'sidebar-waifu-txt txt-smallie',
xalign: 0,
label: name,
}),
MaterialIcon(icon, 'norm'),
]
})
const ImageAction = ({ name, icon, action }) => Button({
className: 'sidebar-waifu-image-action txt-norm icon-material',
tooltipText: name,
label: icon,
onClicked: action,
setup: setupCursorHover,
})
const PreviewImage = (data, delay = 0) => {
const imageArea = Widget.DrawingArea({
className: 'sidebar-booru-image-drawingarea',
});
const imageBox = Box({
className: 'sidebar-booru-image',
// css: `background-image: url('${data.preview_url}');`,
attribute: {
'update': (self, data, force = false) => {
const imagePath = `${USER_CACHE_DIR}/ags/media/waifus/${data.md5}.${data.file_ext}`;
const widgetStyleContext = imageArea.get_style_context();
const widgetWidth = widgetStyleContext.get_property('min-width', Gtk.StateFlags.NORMAL);
const widgetHeight = widgetStyleContext.get_property('min-height', Gtk.StateFlags.NORMAL);
imageArea.set_size_request(widgetWidth, widgetHeight);
const showImage = () => {
const imageDimensionsStr = exec(`identify -format {\\"w\\":%w,\\"h\\":%h} '${imagePath}'`)
const imageDimensionsJson = JSON.parse(imageDimensionsStr);
let imageWidth = imageDimensionsJson.w;
let imageHeight = imageDimensionsJson.h;
// Fill
const scale = imageWidth / imageHeight;
if (imageWidth > imageHeight) {
imageWidth = widgetHeight * scale;
imageHeight = widgetHeight;
} else {
imageHeight = widgetWidth / scale;
imageWidth = widgetWidth;
}
// const pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(imagePath, widgetWidth, widgetHeight);
const pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(imagePath, imageWidth, imageHeight, false);
imageArea.connect("draw", (widget, cr) => {
const borderRadius = widget.get_style_context().get_property('border-radius', Gtk.StateFlags.NORMAL);
// Draw a rounded rectangle
cr.arc(borderRadius, borderRadius, borderRadius, Math.PI, 1.5 * Math.PI);
cr.arc(widgetWidth - borderRadius, borderRadius, borderRadius, 1.5 * Math.PI, 2 * Math.PI);
cr.arc(widgetWidth - borderRadius, widgetHeight - borderRadius, borderRadius, 0, 0.5 * Math.PI);
cr.arc(borderRadius, widgetHeight - borderRadius, borderRadius, 0.5 * Math.PI, Math.PI);
cr.closePath();
cr.clip();
// Paint image as bg
Gdk.cairo_set_source_pixbuf(cr, pixbuf, (widgetWidth - imageWidth) / 2, (widgetHeight - imageHeight) / 2);
cr.paint();
});
self.queue_draw();
}
// Show
// const downloadCommand = `wget -O '${imagePath}' '${data.preview_url}'`;
const downloadCommand = `curl -L -o '${imagePath}' '${data.preview_url}'`;
// console.log(downloadCommand)
if (!force && fileExists(imagePath)) showImage();
else Utils.timeout(delay, () => Utils.execAsync(['bash', '-c', downloadCommand])
.then(showImage)
.catch(print)
);
},
},
child: imageArea,
setup: (self) => {
Utils.timeout(1000, () => self.attribute.update(self, data));
}
});
const imageActions = Box({
vpack: 'start',
className: 'sidebar-booru-image-actions spacing-h-3',
children: [
Box({ hexpand: true }),
ImageAction({
name: 'Go to file url',
icon: 'file_open',
action: () => execAsync(['xdg-open', `${data.file_url}`]).catch(print),
}),
ImageAction({
name: 'Go to source',
icon: 'open_in_new',
action: () => execAsync(['xdg-open', `${data.source}`]).catch(print),
}),
]
});
return Overlay({
child: imageBox,
overlays: [imageActions]
})
}
const downloadState = Stack({
homogeneous: false,
transition: 'slide_up_down',
transitionDuration: userOptions.animations.durationSmall,
children: {
'api': PageState('api', 'Calling API'),
'download': PageState('downloading', 'Downloading image'),
'done': PageState('done', 'Finished!'),
'error': PageState('error', 'Error'),
},
});
const downloadIndicator = MarginRevealer({
vpack: 'center',
transition: 'slide_left',
revealChild: true,
child: downloadState,
});
const pageHeading = Box({
homogeneous: false,
children: [
Scrollable({
hexpand: true,
vscroll: 'never',
hscroll: 'automatic',
child: Box({
hpack: 'fill',
className: 'spacing-h-5',
children: [
...taglist.map((tag) => CommandButton(tag)),
Box({ hexpand: true }),
]
})
}),
downloadIndicator,
]
});
const pageImageGrid = Grid({
// columnHomogeneous: true,
// rowHomogeneous: true,
className: 'sidebar-booru-imagegrid',
});
const pageImageRevealer = Revealer({
transition: 'slide_down',
transitionDuration: userOptions.animations.durationLarge,
revealChild: false,
child: pageImageGrid,
});
const thisPage = Box({
homogeneous: true,
className: 'sidebar-chat-message',
attribute: {
'imagePath': '',
'isNsfw': false,
'imageData': '',
'update': (data, force = false) => {
const imageData = data;
thisPage.attribute.imageData = imageData;
if (data.length == 0) {
downloadState.shown = 'error';
return;
}
const imageColumns = userOptions.sidebar.imageColumns;
const imageRows = data.length / imageColumns;
// Add stuff
for (let i = 0; i < imageRows; i++) {
for (let j = 0; j < imageColumns; j++) {
if (i * imageColumns + j >= Math.min(userOptions.sidebar.imageBooruCount, data.length)) break;
pageImageGrid.attach(
PreviewImage(data[i * imageColumns + j]),
j, i, 1, 1
);
}
}
pageImageGrid.show_all();
// Reveal stuff
Utils.timeout(IMAGE_REVEAL_DELAY,
() => pageImageRevealer.revealChild = true
);
downloadIndicator.attribute.hide();
},
},
children: [Box({
vertical: true,
className: 'spacing-v-5',
children: [
pageHeading,
Box({
vertical: true,
children: [pageImageRevealer],
})
]
})],
});
return thisPage;
}
const booruContent = Box({
className: 'spacing-v-15',
vertical: true,
attribute: {
'map': new Map(),
},
setup: (self) => self
.hook(BooruService, (box, id) => {
if (id === undefined) return;
const newPage = BooruPage(BooruService.queries[id]);
box.add(newPage);
box.show_all();
box.attribute.map.set(id, newPage);
}, 'newResponse')
.hook(BooruService, (box, id) => {
if (id === undefined) return;
const data = BooruService.responses[id];
if (!data) return;
const page = box.attribute.map.get(id);
page?.attribute.update(data);
}, 'updateResponse')
,
});
export const booruView = Scrollable({
className: 'sidebar-chat-viewport',
vexpand: true,
child: Box({
vertical: true,
children: [
booruWelcome,
booruContent,
]
}),
setup: (scrolledWindow) => {
// Show scrollbar
scrolledWindow.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC);
const vScrollbar = scrolledWindow.get_vscrollbar();
vScrollbar.get_style_context().add_class('sidebar-scrollbar');
// Avoid click-to-scroll-widget-to-view behavior
Utils.timeout(1, () => {
const viewport = scrolledWindow.child;
viewport.set_focus_vadjustment(new Gtk.Adjustment(undefined));
})
// Always scroll to bottom with new content
const adjustment = scrolledWindow.get_vadjustment();
adjustment.connect("changed", () => {
if (!chatEntry.hasFocus) return;
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size());
})
}
});
const booruTags = Revealer({
revealChild: false,
transition: 'crossfade',
transitionDuration: userOptions.animations.durationLarge,
child: Box({
className: 'spacing-h-5',
children: [
Scrollable({
vscroll: 'never',
hscroll: 'automatic',
hexpand: true,
child: Box({
className: 'spacing-h-5',
children: [
CommandButton('*'),
CommandButton('hololive'),
]
})
}),
Box({ className: 'separator-line' }),
]
})
});
export const booruCommands = Box({
className: 'spacing-h-5',
setup: (self) => {
self.pack_end(CommandButton('/clear'), false, false, 0);
self.pack_start(Button({
className: 'sidebar-chat-chip-toggle',
setup: setupCursorHover,
label: 'Tags →',
onClicked: () => {
booruTags.revealChild = !booruTags.revealChild;
}
}), false, false, 0);
self.pack_start(booruTags, true, true, 0);
}
});
const clearChat = () => { // destroy!!
booruContent.attribute.map.forEach((value, key, map) => {
value.destroy();
value = null;
});
}
export const sendMessage = (text) => {
// Commands
if (text.startsWith('/')) {
if (text.startsWith('/clear')) clearChat();
}
else BooruService.fetch(text);
}

View file

@ -0,0 +1,364 @@
const { Gtk } = imports.gi;
import App from 'resource:///com/github/Aylur/ags/app.js';
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { Box, Button, Icon, Label, Revealer, Scrollable } = Widget;
import GPTService from '../../../services/gpt.js';
import { setupCursorHover, setupCursorHoverInfo } from '../../.widgetutils/cursorhover.js';
import { SystemMessage, ChatMessage } from "./ai_chatmessage.js";
import { ConfigToggle, ConfigSegmentedSelection, ConfigGap } from '../../.commonwidgets/configwidgets.js';
import { markdownTest } from '../../.miscutils/md2pango.js';
import { MarginRevealer } from '../../.widgethacks/advancedrevealers.js';
import { MaterialIcon } from '../../.commonwidgets/materialicon.js';
import { chatEntry } from '../apiwidgets.js';
export const chatGPTTabIcon = Icon({
hpack: 'center',
icon: `openai-symbolic`,
});
const ProviderSwitcher = () => {
const ProviderChoice = (id, provider) => {
const providerSelected = MaterialIcon('check', 'norm', {
setup: (self) => self.hook(GPTService, (self) => {
self.toggleClassName('invisible', GPTService.providerID !== id);
}, 'providerChanged')
});
return Button({
tooltipText: provider.description,
onClicked: () => {
GPTService.providerID = id;
providerList.revealChild = false;
indicatorChevron.label = 'expand_more';
},
child: Box({
className: 'spacing-h-10 txt',
children: [
Icon({
icon: provider['logo_name'],
className: 'txt-large'
}),
Label({
hexpand: true,
xalign: 0,
className: 'txt-small',
label: provider.name,
}),
providerSelected
],
}),
setup: setupCursorHover,
});
}
const indicatorChevron = MaterialIcon('expand_more', 'norm');
const indicatorButton = Button({
tooltipText: 'Select ChatGPT-compatible API provider',
child: Box({
className: 'spacing-h-10 txt',
children: [
MaterialIcon('cloud', 'norm'),
Label({
hexpand: true,
xalign: 0,
className: 'txt-small',
label: GPTService.providerID,
setup: (self) => self.hook(GPTService, (self) => {
self.label = `${GPTService.providers[GPTService.providerID]['name']}`;
}, 'providerChanged')
}),
indicatorChevron,
]
}),
onClicked: () => {
providerList.revealChild = !providerList.revealChild;
indicatorChevron.label = (providerList.revealChild ? 'expand_less' : 'expand_more');
},
setup: setupCursorHover,
});
const providerList = Revealer({
revealChild: false,
transition: 'slide_down',
transitionDuration: userOptions.animations.durationLarge,
child: Box({
vertical: true, className: 'spacing-v-5 sidebar-chat-providerswitcher-list',
children: [
Box({ className: 'separator-line margin-top-5 margin-bottom-5' }),
Box({
className: 'spacing-v-5',
vertical: true,
setup: (self) => self.hook(GPTService, (self) => {
self.children = Object.entries(GPTService.providers)
.map(([id, provider]) => ProviderChoice(id, provider));
}, 'initialized'),
})
]
})
})
return Box({
hpack: 'center',
vertical: true,
className: 'sidebar-chat-providerswitcher',
children: [
indicatorButton,
providerList,
]
})
}
const GPTInfo = () => {
const openAiLogo = Icon({
hpack: 'center',
className: 'sidebar-chat-welcome-logo',
icon: `openai-symbolic`,
});
return Box({
vertical: true,
className: 'spacing-v-15',
children: [
openAiLogo,
Label({
className: 'txt txt-title-small sidebar-chat-welcome-txt',
wrap: true,
justify: Gtk.Justification.CENTER,
label: 'Assistant (GPTs)',
}),
Box({
className: 'spacing-h-5',
hpack: 'center',
children: [
Label({
className: 'txt-smallie txt-subtext',
wrap: true,
justify: Gtk.Justification.CENTER,
label: 'Provider shown above',
}),
Button({
className: 'txt-subtext txt-norm icon-material',
label: 'info',
tooltipText: 'Uses gpt-3.5-turbo.\nNot affiliated, endorsed, or sponsored by OpenAI.\n\nPrivacy: OpenAI claims they do not use your data\nwhen you use their API. Idk about others.',
setup: setupCursorHoverInfo,
}),
]
}),
]
});
}
const GPTSettings = () => MarginRevealer({
transition: 'slide_down',
revealChild: true,
extraSetup: (self) => self
.hook(GPTService, (self) => Utils.timeout(200, () => {
self.attribute.hide();
}), 'newMsg')
.hook(GPTService, (self) => Utils.timeout(200, () => {
self.attribute.show();
}), 'clear')
,
child: Box({
vertical: true,
className: 'sidebar-chat-settings',
children: [
ConfigSegmentedSelection({
hpack: 'center',
icon: 'casino',
name: 'Randomness',
desc: 'The model\'s temperature value.\n Precise = 0\n Balanced = 0.5\n Creative = 1',
options: [
{ value: 0.00, name: 'Precise', },
{ value: 0.50, name: 'Balanced', },
{ value: 1.00, name: 'Creative', },
],
initIndex: 2,
onChange: (value, name) => {
GPTService.temperature = value;
},
}),
ConfigGap({ vertical: true, size: 10 }), // Note: size can only be 5, 10, or 15
Box({
vertical: true,
hpack: 'fill',
className: 'sidebar-chat-settings-toggles',
children: [
ConfigToggle({
icon: 'cycle',
name: 'Cycle models',
desc: 'Helps avoid exceeding the API rate of 3 messages per minute.\nTurn this on if you message rapidly.',
initValue: GPTService.cycleModels,
onChange: (self, newValue) => {
GPTService.cycleModels = newValue;
},
}),
ConfigToggle({
icon: 'model_training',
name: 'Enhancements',
desc: 'Tells the model:\n- It\'s a Linux sidebar assistant\n- Be brief and use bullet points',
initValue: GPTService.assistantPrompt,
onChange: (self, newValue) => {
GPTService.assistantPrompt = newValue;
},
}),
]
})
]
})
});
export const OpenaiApiKeyInstructions = () => Box({
homogeneous: true,
children: [Revealer({
transition: 'slide_down',
transitionDuration: userOptions.animations.durationLarge,
setup: (self) => self
.hook(GPTService, (self, hasKey) => {
self.revealChild = (GPTService.key.length == 0);
}, 'hasKey')
,
child: Button({
child: Label({
useMarkup: true,
wrap: true,
className: 'txt sidebar-chat-welcome-txt',
justify: Gtk.Justification.CENTER,
label: 'An API key is required\nYou can grab one <u>here</u>, then enter it below'
}),
setup: setupCursorHover,
onClicked: () => {
Utils.execAsync(['bash', '-c', `xdg-open ${GPTService.getKeyUrl}`]);
}
})
})]
});
const GPTWelcome = () => Box({
vexpand: true,
homogeneous: true,
child: Box({
className: 'spacing-v-15',
vpack: 'center',
vertical: true,
children: [
GPTInfo(),
OpenaiApiKeyInstructions(),
GPTSettings(),
]
})
});
export const chatContent = Box({
className: 'spacing-v-5',
vertical: true,
setup: (self) => self
.hook(GPTService, (box, id) => {
const message = GPTService.messages[id];
if (!message) return;
box.add(ChatMessage(message, `Model (${GPTService.providers[GPTService.providerID]['name']})`))
}, 'newMsg')
,
});
const clearChat = () => {
GPTService.clear();
const children = chatContent.get_children();
for (let i = 0; i < children.length; i++) {
const child = children[i];
child.destroy();
}
}
const CommandButton = (command) => Button({
className: 'sidebar-chat-chip sidebar-chat-chip-action txt txt-small',
onClicked: () => sendMessage(command),
setup: setupCursorHover,
label: command,
});
export const chatGPTCommands = Box({
className: 'spacing-h-5',
children: [
Box({ hexpand: true }),
CommandButton('/key'),
CommandButton('/model'),
CommandButton('/clear'),
]
});
export const sendMessage = (text) => {
// Check if text or API key is empty
if (text.length == 0) return;
if (GPTService.key.length == 0) {
GPTService.key = text;
chatContent.add(SystemMessage(`Key saved to\n\`${GPTService.keyPath}\``, 'API Key', chatGPTView));
text = '';
return;
}
// Commands
if (text.startsWith('/')) {
if (text.startsWith('/clear')) clearChat();
else if (text.startsWith('/model')) chatContent.add(SystemMessage(`Currently using \`${GPTService.modelName}\``, '/model', chatGPTView))
else if (text.startsWith('/prompt')) {
const firstSpaceIndex = text.indexOf(' ');
const prompt = text.slice(firstSpaceIndex + 1);
if (firstSpaceIndex == -1 || prompt.length < 1) {
chatContent.add(SystemMessage(`Usage: \`/prompt MESSAGE\``, '/prompt', chatGPTView))
}
else {
GPTService.addMessage('user', prompt)
}
}
else if (text.startsWith('/key')) {
const parts = text.split(' ');
if (parts.length == 1) chatContent.add(SystemMessage(
`Key stored in:\n\`${GPTService.keyPath}\`\nTo update this key, type \`/key YOUR_API_KEY\``,
'/key',
chatGPTView));
else {
GPTService.key = parts[1];
chatContent.add(SystemMessage(`Updated API Key at\n\`${GPTService.keyPath}\``, '/key', chatGPTView));
}
}
else if (text.startsWith('/test'))
chatContent.add(SystemMessage(markdownTest, `Markdown test`, chatGPTView));
else
chatContent.add(SystemMessage(`Invalid command.`, 'Error', chatGPTView))
}
else {
GPTService.send(text);
}
}
export const chatGPTView = Box({
vertical: true,
children: [
ProviderSwitcher(),
Scrollable({
className: 'sidebar-chat-viewport',
vexpand: true,
child: Box({
vertical: true,
children: [
GPTWelcome(),
chatContent,
]
}),
setup: (scrolledWindow) => {
// Show scrollbar
scrolledWindow.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC);
const vScrollbar = scrolledWindow.get_vscrollbar();
vScrollbar.get_style_context().add_class('sidebar-scrollbar');
// Avoid click-to-scroll-widget-to-view behavior
Utils.timeout(1, () => {
const viewport = scrolledWindow.child;
viewport.set_focus_vadjustment(new Gtk.Adjustment(undefined));
})
// Always scroll to bottom with new content
const adjustment = scrolledWindow.get_vadjustment();
adjustment.connect("changed", () => {
if(!chatEntry.hasFocus) return;
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size());
})
}
})
]
});

View file

@ -0,0 +1,288 @@
const { Gtk } = imports.gi;
import App from 'resource:///com/github/Aylur/ags/app.js';
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { Box, Button, Icon, Label, Revealer, Scrollable } = Widget;
import GeminiService from '../../../services/gemini.js';
import { setupCursorHover, setupCursorHoverInfo } from '../../.widgetutils/cursorhover.js';
import { SystemMessage, ChatMessage } from "./ai_chatmessage.js";
import { ConfigToggle, ConfigSegmentedSelection, ConfigGap } from '../../.commonwidgets/configwidgets.js';
import { markdownTest } from '../../.miscutils/md2pango.js';
import { MarginRevealer } from '../../.widgethacks/advancedrevealers.js';
import { chatEntry } from '../apiwidgets.js';
const MODEL_NAME = `Gemini`;
export const geminiTabIcon = Icon({
hpack: 'center',
icon: `google-gemini-symbolic`,
})
const GeminiInfo = () => {
const geminiLogo = Icon({
hpack: 'center',
className: 'sidebar-chat-welcome-logo',
icon: `google-gemini-symbolic`,
});
return Box({
vertical: true,
className: 'spacing-v-15',
children: [
geminiLogo,
Label({
className: 'txt txt-title-small sidebar-chat-welcome-txt',
wrap: true,
justify: Gtk.Justification.CENTER,
label: 'Assistant (Gemini Pro)',
}),
Box({
className: 'spacing-h-5',
hpack: 'center',
children: [
Label({
className: 'txt-smallie txt-subtext',
wrap: true,
justify: Gtk.Justification.CENTER,
label: 'Powered by Google',
}),
Button({
className: 'txt-subtext txt-norm icon-material',
label: 'info',
tooltipText: 'Uses gemini-pro.\nNot affiliated, endorsed, or sponsored by Google.\n\nPrivacy: Chat messages aren\'t linked to your account,\n but will be read by human reviewers to improve the model.',
setup: setupCursorHoverInfo,
}),
]
}),
]
});
}
export const GeminiSettings = () => MarginRevealer({
transition: 'slide_down',
revealChild: true,
extraSetup: (self) => self
.hook(GeminiService, (self) => Utils.timeout(200, () => {
self.attribute.hide();
}), 'newMsg')
.hook(GeminiService, (self) => Utils.timeout(200, () => {
self.attribute.show();
}), 'clear')
,
child: Box({
vertical: true,
className: 'sidebar-chat-settings',
children: [
ConfigSegmentedSelection({
hpack: 'center',
icon: 'casino',
name: 'Randomness',
desc: 'Gemini\'s temperature value.\n Precise = 0\n Balanced = 0.5\n Creative = 1',
options: [
{ value: 0.00, name: 'Precise', },
{ value: 0.50, name: 'Balanced', },
{ value: 1.00, name: 'Creative', },
],
initIndex: 2,
onChange: (value, name) => {
GeminiService.temperature = value;
},
}),
ConfigGap({ vertical: true, size: 10 }), // Note: size can only be 5, 10, or 15
Box({
vertical: true,
hpack: 'fill',
className: 'sidebar-chat-settings-toggles',
children: [
ConfigToggle({
icon: 'model_training',
name: 'Enhancements',
desc: 'Tells Gemini:\n- It\'s a Linux sidebar assistant\n- Be brief and use bullet points',
initValue: GeminiService.assistantPrompt,
onChange: (self, newValue) => {
GeminiService.assistantPrompt = newValue;
},
}),
ConfigToggle({
icon: 'shield',
name: 'Safety',
desc: 'When turned off, tells the API (not the model) \nto not block harmful/explicit content',
initValue: GeminiService.safe,
onChange: (self, newValue) => {
GeminiService.safe = newValue;
},
}),
ConfigToggle({
icon: 'history',
name: 'History',
desc: 'Saves chat history\nMessages in previous chats won\'t show automatically, but they are there',
initValue: GeminiService.useHistory,
onChange: (self, newValue) => {
GeminiService.useHistory = newValue;
},
}),
]
})
]
})
});
export const GoogleAiInstructions = () => Box({
homogeneous: true,
children: [Revealer({
transition: 'slide_down',
transitionDuration: userOptions.animations.durationLarge,
setup: (self) => self
.hook(GeminiService, (self, hasKey) => {
self.revealChild = (GeminiService.key.length == 0);
}, 'hasKey')
,
child: Button({
child: Label({
useMarkup: true,
wrap: true,
className: 'txt sidebar-chat-welcome-txt',
justify: Gtk.Justification.CENTER,
label: 'A Google AI API key is required\nYou can grab one <u>here</u>, then enter it below',
// setup: self => self.set_markup("This is a <a href=\"https://www.github.com\">test link</a>")
}),
setup: setupCursorHover,
onClicked: () => {
Utils.execAsync(['bash', '-c', `xdg-open https://makersuite.google.com/app/apikey &`]);
}
})
})]
});
const geminiWelcome = Box({
vexpand: true,
homogeneous: true,
child: Box({
className: 'spacing-v-15',
vpack: 'center',
vertical: true,
children: [
GeminiInfo(),
GoogleAiInstructions(),
GeminiSettings(),
]
})
});
export const chatContent = Box({
className: 'spacing-v-5',
vertical: true,
setup: (self) => self
.hook(GeminiService, (box, id) => {
const message = GeminiService.messages[id];
if (!message) return;
box.add(ChatMessage(message, MODEL_NAME))
}, 'newMsg')
,
});
const clearChat = () => {
GeminiService.clear();
const children = chatContent.get_children();
for (let i = 0; i < children.length; i++) {
const child = children[i];
child.destroy();
}
}
const CommandButton = (command) => Button({
className: 'sidebar-chat-chip sidebar-chat-chip-action txt txt-small',
onClicked: () => sendMessage(command),
setup: setupCursorHover,
label: command,
});
export const geminiCommands = Box({
className: 'spacing-h-5',
children: [
Box({ hexpand: true }),
CommandButton('/key'),
CommandButton('/model'),
CommandButton('/clear'),
]
});
export const sendMessage = (text) => {
// Check if text or API key is empty
if (text.length == 0) return;
if (GeminiService.key.length == 0) {
GeminiService.key = text;
chatContent.add(SystemMessage(`Key saved to\n\`${GeminiService.keyPath}\``, 'API Key', geminiView));
text = '';
return;
}
// Commands
if (text.startsWith('/')) {
if (text.startsWith('/clear')) clearChat();
else if (text.startsWith('/load')) {
clearChat();
GeminiService.loadHistory();
}
else if (text.startsWith('/model')) chatContent.add(SystemMessage(`Currently using \`${GeminiService.modelName}\``, '/model', geminiView))
else if (text.startsWith('/prompt')) {
const firstSpaceIndex = text.indexOf(' ');
const prompt = text.slice(firstSpaceIndex + 1);
if (firstSpaceIndex == -1 || prompt.length < 1) {
chatContent.add(SystemMessage(`Usage: \`/prompt MESSAGE\``, '/prompt', geminiView))
}
else {
GeminiService.addMessage('user', prompt)
}
}
else if (text.startsWith('/key')) {
const parts = text.split(' ');
if (parts.length == 1) chatContent.add(SystemMessage(
`Key stored in:\n\`${GeminiService.keyPath}\`\nTo update this key, type \`/key YOUR_API_KEY\``,
'/key',
geminiView));
else {
GeminiService.key = parts[1];
chatContent.add(SystemMessage(`Updated API Key at\n\`${GeminiService.keyPath}\``, '/key', geminiView));
}
}
else if (text.startsWith('/test'))
chatContent.add(SystemMessage(markdownTest, `Markdown test`, geminiView));
else
chatContent.add(SystemMessage(`Invalid command.`, 'Error', geminiView))
}
else {
GeminiService.send(text);
}
}
export const geminiView = Box({
homogeneous: true,
children: [Scrollable({
className: 'sidebar-chat-viewport',
vexpand: true,
child: Box({
vertical: true,
children: [
geminiWelcome,
chatContent,
]
}),
setup: (scrolledWindow) => {
// Show scrollbar
scrolledWindow.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC);
const vScrollbar = scrolledWindow.get_vscrollbar();
vScrollbar.get_style_context().add_class('sidebar-scrollbar');
// Avoid click-to-scroll-widget-to-view behavior
Utils.timeout(1, () => {
const viewport = scrolledWindow.child;
viewport.set_focus_vadjustment(new Gtk.Adjustment(undefined));
})
// Always scroll to bottom with new content
const adjustment = scrolledWindow.get_vadjustment();
adjustment.connect("changed", () => Utils.timeout(1, () => {
if(!chatEntry.hasFocus) return;
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size());
}))
}
})]
});

View file

@ -0,0 +1,411 @@
// TODO: execAsync(['identify', '-format', '{"w":%w,"h":%h}', imagePath])
// to detect img dimensions
const { Gdk, GdkPixbuf, Gio, GLib, Gtk } = imports.gi;
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { Box, Button, Label, Overlay, Revealer, Scrollable, Stack } = Widget;
const { execAsync, exec } = Utils;
import { fileExists } from '../../.miscutils/files.js';
import { MaterialIcon } from '../../.commonwidgets/materialicon.js';
import { MarginRevealer } from '../../.widgethacks/advancedrevealers.js';
import { setupCursorHover, setupCursorHoverInfo } from '../../.widgetutils/cursorhover.js';
import WaifuService from '../../../services/waifus.js';
import { darkMode } from '../../.miscutils/system.js';
import { chatEntry } from '../apiwidgets.js';
async function getImageViewerApp(preferredApp) {
Utils.execAsync(['bash', '-c', `command -v ${preferredApp}`])
.then((output) => {
if (output != '') return preferredApp;
else return 'xdg-open';
});
}
const IMAGE_REVEAL_DELAY = 13; // Some wait for inits n other weird stuff
const IMAGE_VIEWER_APP = getImageViewerApp(userOptions.apps.imageViewer); // Gnome's image viewer cuz very comfortable zooming
const USER_CACHE_DIR = GLib.get_user_cache_dir();
// Create cache folder and clear pics from previous session
Utils.exec(`bash -c 'mkdir -p ${USER_CACHE_DIR}/ags/media/waifus'`);
Utils.exec(`bash -c 'rm ${USER_CACHE_DIR}/ags/media/waifus/*'`);
const CommandButton = (command) => Button({
className: 'sidebar-chat-chip sidebar-chat-chip-action txt txt-small',
onClicked: () => sendMessage(command),
setup: setupCursorHover,
label: command,
});
export const waifuTabIcon = Box({
hpack: 'center',
children: [
MaterialIcon('photo', 'norm'),
]
});
const WaifuInfo = () => {
const waifuLogo = Label({
hpack: 'center',
className: 'sidebar-chat-welcome-logo',
label: 'photo',
})
return Box({
vertical: true,
vexpand: true,
className: 'spacing-v-15',
children: [
waifuLogo,
Label({
className: 'txt txt-title-small sidebar-chat-welcome-txt',
wrap: true,
justify: Gtk.Justification.CENTER,
label: 'Waifus',
}),
Box({
className: 'spacing-h-5',
hpack: 'center',
children: [
Label({
className: 'txt-smallie txt-subtext',
wrap: true,
justify: Gtk.Justification.CENTER,
label: 'Powered by waifu.im + other APIs',
}),
Button({
className: 'txt-subtext txt-norm icon-material',
label: 'info',
tooltipText: 'Type tags for a random pic.\nNSFW content will not be returned unless\nyou explicitly request such a tag.\n\nDisclaimer: Not affiliated with the providers\nnor responsible for any of their content.',
setup: setupCursorHoverInfo,
}),
]
}),
]
});
}
const waifuWelcome = Box({
vexpand: true,
homogeneous: true,
child: Box({
className: 'spacing-v-15',
vpack: 'center',
vertical: true,
children: [
WaifuInfo(),
]
})
});
const WaifuImage = (taglist) => {
const ImageState = (icon, name) => Box({
className: 'spacing-h-5 txt',
children: [
Box({ hexpand: true }),
Label({
className: 'sidebar-waifu-txt txt-smallie',
xalign: 0,
label: name,
}),
MaterialIcon(icon, 'norm'),
]
})
const ImageAction = ({ name, icon, action }) => Button({
className: 'sidebar-waifu-image-action txt-norm icon-material',
tooltipText: name,
label: icon,
onClicked: action,
setup: setupCursorHover,
})
const downloadState = Stack({
homogeneous: false,
transition: 'slide_up_down',
transitionDuration: userOptions.animations.durationSmall,
children: {
'api': ImageState('api', 'Calling API'),
'download': ImageState('downloading', 'Downloading image'),
'done': ImageState('done', 'Finished!'),
'error': ImageState('error', 'Error'),
},
});
const downloadIndicator = MarginRevealer({
vpack: 'center',
transition: 'slide_left',
revealChild: true,
child: downloadState,
});
const blockHeading = Box({
hpack: 'fill',
className: 'spacing-h-5',
children: [
...taglist.map((tag) => CommandButton(tag)),
Box({ hexpand: true }),
downloadIndicator,
]
});
const blockImageActions = Revealer({
transition: 'crossfade',
revealChild: false,
child: Box({
vertical: true,
children: [
Box({
className: 'sidebar-waifu-image-actions spacing-h-3',
children: [
Box({ hexpand: true }),
ImageAction({
name: 'Go to source',
icon: 'link',
action: () => execAsync(['xdg-open', `${thisBlock.attribute.imageData.source}`]).catch(print),
}),
ImageAction({
name: 'Hoard',
icon: 'save',
action: (self) => {
execAsync(['bash', '-c', `mkdir -p ~/Pictures/homework${thisBlock.attribute.isNsfw ? '/🌶️' : ''} && cp ${thisBlock.attribute.imagePath} ~/Pictures/homework${thisBlock.attribute.isNsfw ? '/🌶️/' : ''}`])
.then(() => self.label = 'done')
.catch(print);
},
}),
ImageAction({
name: 'Open externally',
icon: 'open_in_new',
action: () => execAsync([IMAGE_VIEWER_APP, `${thisBlock.attribute.imagePath}`]).catch(print),
}),
]
})
],
})
})
const blockImage = Widget.DrawingArea({
className: 'sidebar-waifu-image',
});
const blockImageRevealer = Revealer({
transition: 'slide_down',
transitionDuration: userOptions.animations.durationLarge,
revealChild: false,
child: Overlay({
child: Box({
homogeneous: true,
className: 'sidebar-waifu-image',
children: [blockImage],
}),
overlays: [blockImageActions],
}),
});
const thisBlock = Box({
className: 'sidebar-chat-message',
attribute: {
'imagePath': '',
'isNsfw': false,
'imageData': '',
'update': (imageData, force = false) => {
thisBlock.attribute.imageData = imageData;
const { status, signature, url, extension, source, dominant_color, is_nsfw, width, height, tags } = thisBlock.attribute.imageData;
thisBlock.attribute.isNsfw = is_nsfw;
if (status != 200) {
downloadState.shown = 'error';
return;
}
thisBlock.attribute.imagePath = `${USER_CACHE_DIR}/ags/media/waifus/${signature}${extension}`;
downloadState.shown = 'download';
// Width/height
const widgetWidth = Math.min(Math.floor(waifuContent.get_allocated_width() * 0.85), width);
const widgetHeight = Math.ceil(widgetWidth * height / width);
blockImage.set_size_request(widgetWidth, widgetHeight);
const showImage = () => {
downloadState.shown = 'done';
const pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(thisBlock.attribute.imagePath, widgetWidth, widgetHeight);
// const pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(thisBlock.attribute.imagePath, widgetWidth, widgetHeight, false);
blockImage.set_size_request(widgetWidth, widgetHeight);
blockImage.connect("draw", (widget, cr) => {
const borderRadius = widget.get_style_context().get_property('border-radius', Gtk.StateFlags.NORMAL);
// Draw a rounded rectangle
cr.arc(borderRadius, borderRadius, borderRadius, Math.PI, 1.5 * Math.PI);
cr.arc(widgetWidth - borderRadius, borderRadius, borderRadius, 1.5 * Math.PI, 2 * Math.PI);
cr.arc(widgetWidth - borderRadius, widgetHeight - borderRadius, borderRadius, 0, 0.5 * Math.PI);
cr.arc(borderRadius, widgetHeight - borderRadius, borderRadius, 0.5 * Math.PI, Math.PI);
cr.closePath();
cr.clip();
// Paint image as bg
Gdk.cairo_set_source_pixbuf(cr, pixbuf, 0, 0);
cr.paint();
});
// Reveal stuff
Utils.timeout(IMAGE_REVEAL_DELAY, () => {
blockImageRevealer.revealChild = true;
})
Utils.timeout(IMAGE_REVEAL_DELAY + blockImageRevealer.transitionDuration,
() => blockImageActions.revealChild = true
);
downloadIndicator.attribute.hide();
}
// Show
if (!force && fileExists(thisBlock.attribute.imagePath)) showImage();
else Utils.execAsync(['bash', '-c', `wget -O '${thisBlock.attribute.imagePath}' '${url}'`])
.then(showImage)
.catch(print);
thisBlock.css = `background-color: mix(${darkMode.value ? 'black' : 'white'}, ${dominant_color}, 0.97);`;
},
},
children: [
Box({
vertical: true,
className: 'spacing-v-5',
children: [
blockHeading,
Box({
vertical: true,
hpack: 'start',
children: [blockImageRevealer],
})
]
})
],
});
return thisBlock;
}
const waifuContent = Box({
className: 'spacing-v-15',
vertical: true,
attribute: {
'map': new Map(),
},
setup: (self) => self
.hook(WaifuService, (box, id) => {
if (id === undefined) return;
const newImageBlock = WaifuImage(WaifuService.queries[id]);
box.add(newImageBlock);
box.show_all();
box.attribute.map.set(id, newImageBlock);
}, 'newResponse')
.hook(WaifuService, (box, id) => {
if (id === undefined) return;
const data = WaifuService.responses[id];
if (!data) return;
const imageBlock = box.attribute.map.get(id);
imageBlock?.attribute.update(data);
}, 'updateResponse')
,
});
export const waifuView = Scrollable({
className: 'sidebar-chat-viewport',
vexpand: true,
child: Box({
vertical: true,
children: [
waifuWelcome,
waifuContent,
]
}),
setup: (scrolledWindow) => {
// Show scrollbar
scrolledWindow.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC);
const vScrollbar = scrolledWindow.get_vscrollbar();
vScrollbar.get_style_context().add_class('sidebar-scrollbar');
// Avoid click-to-scroll-widget-to-view behavior
Utils.timeout(1, () => {
const viewport = scrolledWindow.child;
viewport.set_focus_vadjustment(new Gtk.Adjustment(undefined));
})
// Always scroll to bottom with new content
const adjustment = scrolledWindow.get_vadjustment();
adjustment.connect("changed", () => {
if (!chatEntry.hasFocus) return;
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size());
})
}
});
const waifuTags = Revealer({
revealChild: false,
transition: 'crossfade',
transitionDuration: userOptions.animations.durationLarge,
child: Box({
className: 'spacing-h-5',
children: [
Scrollable({
vscroll: 'never',
hscroll: 'automatic',
hexpand: true,
child: Box({
className: 'spacing-h-5',
children: [
CommandButton('waifu'),
CommandButton('maid'),
CommandButton('uniform'),
CommandButton('oppai'),
CommandButton('selfies'),
CommandButton('marin-kitagawa'),
CommandButton('raiden-shogun'),
CommandButton('mori-calliope'),
]
})
}),
Box({ className: 'separator-line' }),
]
})
});
export const waifuCommands = Box({
className: 'spacing-h-5',
setup: (self) => {
self.pack_end(CommandButton('/clear'), false, false, 0);
self.pack_start(Button({
className: 'sidebar-chat-chip-toggle',
setup: setupCursorHover,
label: 'Tags →',
onClicked: () => {
waifuTags.revealChild = !waifuTags.revealChild;
}
}), false, false, 0);
self.pack_start(waifuTags, true, true, 0);
}
});
const clearChat = () => { // destroy!!
waifuContent.attribute.map.forEach((value, key, map) => {
value.destroy();
value = null;
});
}
function newSimpleImageCall(name, url, width, height, dominantColor = '#9392A6') {
const timeSinceEpoch = Date.now();
const newImage = WaifuImage([`/${name}`]);
waifuContent.add(newImage);
waifuContent.attribute.map.set(timeSinceEpoch, newImage);
Utils.timeout(IMAGE_REVEAL_DELAY, () => newImage?.attribute.update({
status: 200,
url: url,
extension: '',
signature: timeSinceEpoch,
source: url,
dominant_color: dominantColor,
is_nsfw: false,
width: width,
height: height,
tags: [`/${name}`],
}, true));
}
export const sendMessage = (text) => {
// Commands
if (text.startsWith('/')) {
if (text.startsWith('/clear')) clearChat();
else if (text.startsWith('/test'))
newSimpleImageCall('test', 'https://picsum.photos/600/400', 300, 200);
else if (text.startsWith('/chino'))
newSimpleImageCall('chino', 'https://chino.pages.dev/chino', 300, 400, '#B2AEF3');
else if (text.startsWith('/place'))
newSimpleImageCall('place', 'https://placewaifu.com/image/400/600', 400, 600, '#F0A235');
}
else WaifuService.fetch(text);
}

View file

@ -0,0 +1,219 @@
const { Gtk, Gdk } = imports.gi;
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { Box, Button, CenterBox, Entry, EventBox, Icon, Label, Overlay, Revealer, Scrollable, Stack } = Widget;
const { execAsync, exec } = Utils;
import { setupCursorHover, setupCursorHoverInfo } from '../.widgetutils/cursorhover.js';
// APIs
import GPTService from '../../services/gpt.js';
import Gemini from '../../services/gemini.js';
import { geminiView, geminiCommands, sendMessage as geminiSendMessage, geminiTabIcon } from './apis/gemini.js';
import { chatGPTView, chatGPTCommands, sendMessage as chatGPTSendMessage, chatGPTTabIcon } from './apis/chatgpt.js';
import { waifuView, waifuCommands, sendMessage as waifuSendMessage, waifuTabIcon } from './apis/waifu.js';
import { booruView, booruCommands, sendMessage as booruSendMessage, booruTabIcon } from './apis/booru.js';
import { enableClickthrough } from "../.widgetutils/clickthrough.js";
import { checkKeybind } from '../.widgetutils/keybind.js';
const TextView = Widget.subclass(Gtk.TextView, "AgsTextView");
import { widgetContent } from './sideleft.js';
import { IconTabContainer } from '../.commonwidgets/tabcontainer.js';
const EXPAND_INPUT_THRESHOLD = 30;
const APIS = [
{
name: 'Assistant (Gemini Pro)',
sendCommand: geminiSendMessage,
contentWidget: geminiView,
commandBar: geminiCommands,
tabIcon: geminiTabIcon,
placeholderText: 'Message Gemini...',
},
{
name: 'Assistant (GPTs)',
sendCommand: chatGPTSendMessage,
contentWidget: chatGPTView,
commandBar: chatGPTCommands,
tabIcon: chatGPTTabIcon,
placeholderText: 'Message the model...',
},
{
name: 'Waifus',
sendCommand: waifuSendMessage,
contentWidget: waifuView,
commandBar: waifuCommands,
tabIcon: waifuTabIcon,
placeholderText: 'Enter tags',
},
{
name: 'Booru',
sendCommand: booruSendMessage,
contentWidget: booruView,
commandBar: booruCommands,
tabIcon: booruTabIcon,
placeholderText: 'Enter tags',
},
];
let currentApiId = 0;
function apiSendMessage(textView) {
// Get text
const buffer = textView.get_buffer();
const [start, end] = buffer.get_bounds();
const text = buffer.get_text(start, end, true).trimStart();
if (!text || text.length == 0) return;
// Send
APIS[currentApiId].sendCommand(text)
// Reset
buffer.set_text("", -1);
chatEntryWrapper.toggleClassName('sidebar-chat-wrapper-extended', false);
chatEntry.set_valign(Gtk.Align.CENTER);
}
export const chatEntry = TextView({
hexpand: true,
wrapMode: Gtk.WrapMode.WORD_CHAR,
acceptsTab: false,
className: 'sidebar-chat-entry txt txt-smallie',
setup: (self) => self
.hook(App, (self, currentName, visible) => {
if (visible && currentName === 'sideleft') {
self.grab_focus();
}
})
.hook(GPTService, (self) => {
if (APIS[currentApiId].name != 'Assistant (GPTs)') return;
self.placeholderText = (GPTService.key.length > 0 ? 'Message the model...' : 'Enter API Key...');
}, 'hasKey')
.hook(Gemini, (self) => {
if (APIS[currentApiId].name != 'Assistant (Gemini Pro)') return;
self.placeholderText = (Gemini.key.length > 0 ? 'Message Gemini...' : 'Enter Google AI API Key...');
}, 'hasKey')
.on("key-press-event", (widget, event) => {
// Don't send when Shift+Enter
if (event.get_keyval()[1] === Gdk.KEY_Return && event.get_state()[1] == Gdk.ModifierType.MOD2_MASK) {
apiSendMessage(widget);
return true;
}
// Keybinds
if (checkKeybind(event, userOptions.keybinds.sidebar.cycleTab))
widgetContent.cycleTab();
else if (checkKeybind(event, userOptions.keybinds.sidebar.nextTab))
widgetContent.nextTab();
else if (checkKeybind(event, userOptions.keybinds.sidebar.prevTab))
widgetContent.prevTab();
else if (checkKeybind(event, userOptions.keybinds.sidebar.apis.nextTab)) {
apiWidgets.attribute.nextTab();
return true;
}
else if (checkKeybind(event, userOptions.keybinds.sidebar.apis.prevTab)) {
apiWidgets.attribute.prevTab();
return true;
}
})
,
});
chatEntry.get_buffer().connect("changed", (buffer) => {
const bufferText = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), true);
chatSendButton.toggleClassName('sidebar-chat-send-available', bufferText.length > 0);
chatPlaceholderRevealer.revealChild = (bufferText.length == 0);
if (buffer.get_line_count() > 1 || bufferText.length > EXPAND_INPUT_THRESHOLD) {
chatEntryWrapper.toggleClassName('sidebar-chat-wrapper-extended', true);
chatEntry.set_valign(Gtk.Align.FILL);
chatPlaceholder.set_valign(Gtk.Align.FILL);
}
else {
chatEntryWrapper.toggleClassName('sidebar-chat-wrapper-extended', false);
chatEntry.set_valign(Gtk.Align.CENTER);
chatPlaceholder.set_valign(Gtk.Align.CENTER);
}
});
const chatEntryWrapper = Scrollable({
className: 'sidebar-chat-wrapper',
hscroll: 'never',
vscroll: 'always',
child: chatEntry,
});
const chatSendButton = Button({
className: 'txt-norm icon-material sidebar-chat-send',
vpack: 'end',
label: 'arrow_upward',
setup: setupCursorHover,
onClicked: (self) => {
APIS[currentApiId].sendCommand(chatEntry.get_buffer().text);
chatEntry.get_buffer().set_text("", -1);
},
});
const chatPlaceholder = Label({
className: 'txt-subtext txt-smallie margin-left-5',
hpack: 'start',
vpack: 'center',
label: APIS[currentApiId].placeholderText,
});
const chatPlaceholderRevealer = Revealer({
revealChild: true,
transition: 'crossfade',
transitionDuration: userOptions.animations.durationLarge,
child: chatPlaceholder,
setup: enableClickthrough,
});
const textboxArea = Box({ // Entry area
className: 'sidebar-chat-textarea',
children: [
Overlay({
passThrough: true,
child: chatEntryWrapper,
overlays: [chatPlaceholderRevealer],
}),
Box({ className: 'width-10' }),
chatSendButton,
]
});
const apiCommandStack = Stack({
transition: 'slide_up_down',
transitionDuration: userOptions.animations.durationLarge,
children: APIS.reduce((acc, api) => {
acc[api.name] = api.commandBar;
return acc;
}, {}),
})
export const apiContentStack = IconTabContainer({
tabSwitcherClassName: 'sidebar-icontabswitcher',
className: 'margin-top-5',
iconWidgets: APIS.map((api) => api.tabIcon),
names: APIS.map((api) => api.name),
children: APIS.map((api) => api.contentWidget),
onChange: (self, id) => {
apiCommandStack.shown = APIS[id].name;
chatPlaceholder.label = APIS[id].placeholderText;
currentApiId = id;
}
});
function switchToTab(id) {
apiContentStack.shown.value = id;
}
const apiWidgets = Widget.Box({
attribute: {
'nextTab': () => switchToTab(Math.min(currentApiId + 1, APIS.length - 1)),
'prevTab': () => switchToTab(Math.max(0, currentApiId - 1)),
},
vertical: true,
className: 'spacing-v-10',
homogeneous: false,
children: [
apiContentStack,
apiCommandStack,
textboxArea,
],
});
export default apiWidgets;

View file

@ -0,0 +1,12 @@
import PopupWindow from '../.widgethacks/popupwindow.js';
import SidebarLeft from "./sideleft.js";
export default () => PopupWindow({
keymode: 'exclusive',
anchor: ['left', 'top', 'bottom'],
name: 'sideleft',
layer: 'top',
showClassName: 'sideleft-show',
hideClassName: 'sideleft-hide',
child: SidebarLeft(),
});

View file

@ -0,0 +1,126 @@
const { Gdk } = imports.gi;
import App from 'resource:///com/github/Aylur/ags/app.js';
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { Box, Button, EventBox, Label, Revealer, Scrollable, Stack } = Widget;
const { execAsync, exec } = Utils;
import { MaterialIcon } from '../.commonwidgets/materialicon.js';
import { setupCursorHover } from '../.widgetutils/cursorhover.js';
import toolBox from './toolbox.js';
import apiWidgets from './apiwidgets.js';
import { chatEntry } from './apiwidgets.js';
import { TabContainer } from '../.commonwidgets/tabcontainer.js';
import { checkKeybind } from '../.widgetutils/keybind.js';
const contents = [
{
name: 'apis',
content: apiWidgets,
materialIcon: 'api',
friendlyName: 'APIs',
},
{
name: 'tools',
content: toolBox,
materialIcon: 'home_repair_service',
friendlyName: 'Tools',
},
]
const pinButton = Button({
attribute: {
'enabled': false,
'toggle': (self) => {
self.attribute.enabled = !self.attribute.enabled;
self.toggleClassName('sidebar-pin-enabled', self.attribute.enabled);
const sideleftWindow = App.getWindow('sideleft');
const sideleftContent = sideleftWindow.get_children()[0].get_children()[0].get_children()[1];
sideleftContent.toggleClassName('sidebar-pinned', self.attribute.enabled);
if (self.attribute.enabled) {
sideleftWindow.exclusivity = 'exclusive';
}
else {
sideleftWindow.exclusivity = 'normal';
}
},
},
vpack: 'start',
className: 'sidebar-pin',
child: MaterialIcon('push_pin', 'larger'),
tooltipText: 'Pin sidebar (Ctrl+P)',
onClicked: (self) => self.attribute.toggle(self),
setup: (self) => {
setupCursorHover(self);
self.hook(App, (self, currentName, visible) => {
if (currentName === 'sideleft' && visible) self.grab_focus();
})
},
})
export const widgetContent = TabContainer({
icons: contents.map((item) => item.materialIcon),
names: contents.map((item) => item.friendlyName),
children: contents.map((item) => item.content),
className: 'sidebar-left spacing-v-10',
setup: (self) => self.hook(App, (self, currentName, visible) => {
if (currentName === 'sideleft')
self.toggleClassName('sidebar-pinned', pinButton.attribute.enabled && visible);
}),
});
export default () => Box({
// vertical: true,
vexpand: true,
hexpand: true,
css: 'min-width: 2px;',
children: [
EventBox({
onPrimaryClick: () => App.closeWindow('sideleft'),
onSecondaryClick: () => App.closeWindow('sideleft'),
onMiddleClick: () => App.closeWindow('sideleft'),
}),
widgetContent,
],
setup: (self) => self
.on('key-press-event', (widget, event) => { // Handle keybinds
if (checkKeybind(event, userOptions.keybinds.sidebar.pin))
pinButton.attribute.toggle(pinButton);
else if (checkKeybind(event, userOptions.keybinds.sidebar.cycleTab))
widgetContent.cycleTab();
else if (checkKeybind(event, userOptions.keybinds.sidebar.nextTab))
widgetContent.nextTab();
else if (checkKeybind(event, userOptions.keybinds.sidebar.prevTab))
widgetContent.prevTab();
if (widgetContent.attribute.names[widgetContent.attribute.shown.value] == 'APIs') { // If api tab is focused
// Focus entry when typing
if ((
!(event.get_state()[1] & Gdk.ModifierType.CONTROL_MASK) &&
event.get_keyval()[1] >= 32 && event.get_keyval()[1] <= 126 &&
widget != chatEntry && event.get_keyval()[1] != Gdk.KEY_space)
||
((event.get_state()[1] & Gdk.ModifierType.CONTROL_MASK) &&
event.get_keyval()[1] === Gdk.KEY_v)
) {
chatEntry.grab_focus();
const buffer = chatEntry.get_buffer();
buffer.set_text(buffer.text + String.fromCharCode(event.get_keyval()[1]), -1);
buffer.place_cursor(buffer.get_iter_at_offset(-1));
}
// Switch API type
else if (checkKeybind(event, userOptions.keybinds.sidebar.apis.nextTab)) {
const toSwitchTab = widgetContent.attribute.children[widgetContent.attribute.shown.value];
toSwitchTab.attribute.nextTab();
}
else if (checkKeybind(event, userOptions.keybinds.sidebar.apis.prevTab)) {
const toSwitchTab = widgetContent.attribute.children[widgetContent.attribute.shown.value];
toSwitchTab.attribute.prevTab();
}
}
})
,
});

View file

@ -0,0 +1,17 @@
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
const { Box, Scrollable } = Widget;
import QuickScripts from './tools/quickscripts.js';
import ColorPicker from './tools/colorpicker.js';
export default Scrollable({
hscroll: "never",
vscroll: "automatic",
child: Box({
vertical: true,
className: 'spacing-v-10',
children: [
QuickScripts(),
ColorPicker(),
]
})
});

View file

@ -0,0 +1,198 @@
// It's weird, I know
const { Gio, GLib } = imports.gi;
import Service from 'resource:///com/github/Aylur/ags/service.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { exec, execAsync } = Utils;
import { clamp } from '../../.miscutils/mathfuncs.js';
export class ColorPickerSelection extends Service {
static {
Service.register(this, {
'picked': [],
'assigned': ['int'],
'hue': [],
'sl': [],
});
}
_hue = 198;
_xAxis = 94;
_yAxis = 80;
get hue() { return this._hue; }
set hue(value) {
this._hue = clamp(value, 0, 360);
this.emit('hue');
this.emit('picked');
this.emit('changed');
}
get xAxis() { return this._xAxis; }
set xAxis(value) {
this._xAxis = clamp(value, 0, 100);
this.emit('sl');
this.emit('picked');
this.emit('changed');
}
get yAxis() { return this._yAxis; }
set yAxis(value) {
this._yAxis = clamp(value, 0, 100);
this.emit('sl');
this.emit('picked');
this.emit('changed');
}
setColorFromHex(hexString, id) {
const hsl = hexToHSL(hexString);
this._hue = hsl.hue;
this._xAxis = hsl.saturation;
// this._yAxis = hsl.lightness;
this._yAxis = (100 - hsl.saturation / 2) / 100 * hsl.lightness;
// console.log(this._hue, this._xAxis, this._yAxis)
this.emit('assigned', id);
this.emit('changed');
}
constructor() {
super();
this.emit('changed');
}
}
export function hslToRgbValues(h, s, l) {
h /= 360;
s /= 100;
l /= 100;
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
const to255 = x => Math.round(x * 255);
r = to255(r);
g = to255(g);
b = to255(b);
return `${Math.round(r)},${Math.round(g)},${Math.round(b)}`;
// return `rgb(${r},${g},${b})`;
}
export function hslToHex(h, s, l) {
h /= 360;
s /= 100;
l /= 100;
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
const toHex = x => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? "0" + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// export function hexToHSL(hex) {
// // Remove the '#' if present
// hex = hex.replace(/^#/, '');
// // Parse the hex value into RGB components
// const bigint = parseInt(hex, 16);
// const r = (bigint >> 16) & 255;
// const g = (bigint >> 8) & 255;
// const b = bigint & 255;
// // Normalize RGB values to range [0, 1]
// const normalizedR = r / 255;
// const normalizedG = g / 255;
// const normalizedB = b / 255;
// // Find the maximum and minimum values
// const max = Math.max(normalizedR, normalizedG, normalizedB);
// const min = Math.min(normalizedR, normalizedG, normalizedB);
// // Calculate the lightness
// const lightness = (max + min) / 2;
// // If the color is grayscale, set saturation to 0
// if (max === min) {
// return {
// hue: 0,
// saturation: 0,
// lightness: lightness * 100 // Convert to percentage
// };
// }
// // Calculate the saturation
// const d = max - min;
// const saturation = lightness > 0.5 ? d / (2 - max - min) : d / (max + min);
// // Calculate the hue
// let hue;
// if (max === normalizedR) {
// hue = ((normalizedG - normalizedB) / d + (normalizedG < normalizedB ? 6 : 0)) * 60;
// } else if (max === normalizedG) {
// hue = ((normalizedB - normalizedR) / d + 2) * 60;
// } else {
// hue = ((normalizedR - normalizedG) / d + 4) * 60;
// }
// return {
// hue: Math.round(hue),
// saturation: Math.round(saturation * 100), // Convert to percentage
// lightness: Math.round(lightness * 100) // Convert to percentage
// };
// }
export function hexToHSL(hex) {
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
var r = parseInt(result[1], 16);
var g = parseInt(result[2], 16);
var b = parseInt(result[3], 16);
r /= 255, g /= 255, b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b);
var h, s, l = (max + min) / 2;
if (max == min) {
h = s = 0; // achromatic
} else {
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
s = s * 100;
s = Math.round(s);
l = l * 100;
l = Math.round(l);
h = Math.round(360 * h);
return {
hue: h,
saturation: s,
lightness: l
};
}

View file

@ -0,0 +1,283 @@
// TODO: Make selection update when entry changes
const { Gtk } = imports.gi;
import App from 'resource:///com/github/Aylur/ags/app.js';
import Variable from 'resource:///com/github/Aylur/ags/variable.js';
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { execAsync, exec } = Utils;
const { Box, Button, Entry, EventBox, Icon, Label, Overlay, Scrollable } = Widget;
import SidebarModule from './module.js';
import { MaterialIcon } from '../../.commonwidgets/materialicon.js';
import { setupCursorHover } from '../../.widgetutils/cursorhover.js';
import { ColorPickerSelection, hslToHex, hslToRgbValues, hexToHSL } from './color.js';
import { clamp } from '../../.miscutils/mathfuncs.js';
export default () => {
const selectedColor = new ColorPickerSelection();
function shouldUseBlackColor() {
return ((selectedColor.xAxis < 40 || (45 <= selectedColor.hue && selectedColor.hue <= 195)) &&
selectedColor.yAxis > 60);
}
const colorBlack = 'rgba(0,0,0,0.9)';
const colorWhite = 'rgba(255,255,255,0.9)';
const hueRange = Box({
homogeneous: true,
className: 'sidebar-module-colorpicker-wrapper',
children: [Box({
className: 'sidebar-module-colorpicker-hue',
css: `background: linear-gradient(to bottom, #ff6666, #ffff66, #66dd66, #66ffff, #6666ff, #ff66ff, #ff6666);`,
})],
});
const hueSlider = Box({
vpack: 'start',
className: 'sidebar-module-colorpicker-cursorwrapper',
css: `margin-top: ${13.636 * selectedColor.hue / 360}rem;`,
homogeneous: true,
children: [Box({
className: 'sidebar-module-colorpicker-hue-cursor',
})],
setup: (self) => self.hook(selectedColor, () => {
const widgetHeight = hueRange.children[0].get_allocated_height();
self.setCss(`margin-top: ${13.636 * selectedColor.hue / 360}rem;`)
}),
});
const hueSelector = Box({
children: [EventBox({
child: Overlay({
child: hueRange,
overlays: [hueSlider],
}),
attribute: {
clicked: false,
setHue: (self, event) => {
const widgetHeight = hueRange.children[0].get_allocated_height();
const [_, cursorX, cursorY] = event.get_coords();
const cursorYPercent = clamp(cursorY / widgetHeight, 0, 1);
selectedColor.hue = Math.round(cursorYPercent * 360);
}
},
setup: (self) => self
.on('motion-notify-event', (self, event) => {
if (!self.attribute.clicked) return;
self.attribute.setHue(self, event);
})
.on('button-press-event', (self, event) => {
if (!(event.get_button()[1] === 1)) return; // We're only interested in left-click here
self.attribute.clicked = true;
self.attribute.setHue(self, event);
})
.on('button-release-event', (self) => self.attribute.clicked = false)
,
})]
});
const saturationAndLightnessRange = Box({
homogeneous: true,
children: [Box({
className: 'sidebar-module-colorpicker-saturationandlightness',
attribute: {
update: (self) => {
// css: `background: linear-gradient(to right, #ffffff, color);`,
self.setCss(`background:
linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1)),
linear-gradient(to right, #ffffff, ${hslToHex(selectedColor.hue, 100, 50)});
`);
},
},
setup: (self) => self
.hook(selectedColor, self.attribute.update, 'hue')
.hook(selectedColor, self.attribute.update, 'assigned')
,
})],
});
const saturationAndLightnessCursor = Box({
className: 'sidebar-module-colorpicker-saturationandlightness-cursorwrapper',
children: [Box({
vpack: 'start',
hpack: 'start',
homogeneous: true,
css: `
margin-left: ${13.636 * selectedColor.xAxis / 100}rem;
margin-top: ${13.636 * (100 - selectedColor.yAxis) / 100}rem;
`, // Why 13.636rem? see class name in stylesheet
attribute: {
update: (self) => {
const allocation = saturationAndLightnessRange.children[0].get_allocation();
self.setCss(`
margin-left: ${13.636 * selectedColor.xAxis / 100}rem;
margin-top: ${13.636 * (100 - selectedColor.yAxis) / 100}rem;
`); // Why 13.636rem? see class name in stylesheet
}
},
setup: (self) => self
.hook(selectedColor, self.attribute.update, 'sl')
.hook(selectedColor, self.attribute.update, 'assigned')
,
children: [Box({
className: 'sidebar-module-colorpicker-saturationandlightness-cursor',
css: `
background-color: ${hslToHex(selectedColor.hue, selectedColor.xAxis, selectedColor.yAxis / (1 + selectedColor.xAxis / 100))};
border-color: ${shouldUseBlackColor() ? colorBlack : colorWhite};
`,
attribute: {
update: (self) => {
self.setCss(`
background-color: ${hslToHex(selectedColor.hue, selectedColor.xAxis, selectedColor.yAxis / (1 + selectedColor.xAxis / 100))};
border-color: ${shouldUseBlackColor() ? colorBlack : colorWhite};
`);
}
},
setup: (self) => self
.hook(selectedColor, self.attribute.update, 'sl')
.hook(selectedColor, self.attribute.update, 'hue')
.hook(selectedColor, self.attribute.update, 'assigned')
,
})],
})]
});
const saturationAndLightnessSelector = Box({
homogeneous: true,
className: 'sidebar-module-colorpicker-saturationandlightness-wrapper',
children: [EventBox({
child: Overlay({
child: saturationAndLightnessRange,
overlays: [saturationAndLightnessCursor],
}),
attribute: {
clicked: false,
setSaturationAndLightness: (self, event) => {
const allocation = saturationAndLightnessRange.children[0].get_allocation();
const [_, cursorX, cursorY] = event.get_coords();
const cursorXPercent = clamp(cursorX / allocation.width, 0, 1);
const cursorYPercent = clamp(cursorY / allocation.height, 0, 1);
selectedColor.xAxis = Math.round(cursorXPercent * 100);
selectedColor.yAxis = Math.round(100 - cursorYPercent * 100);
}
},
setup: (self) => self
.on('motion-notify-event', (self, event) => {
if (!self.attribute.clicked) return;
self.attribute.setSaturationAndLightness(self, event);
})
.on('button-press-event', (self, event) => {
if (!(event.get_button()[1] === 1)) return; // We're only interested in left-click here
self.attribute.clicked = true;
self.attribute.setSaturationAndLightness(self, event);
})
.on('button-release-event', (self) => self.attribute.clicked = false)
,
})]
});
const resultColorBox = Box({
className: 'sidebar-module-colorpicker-result-box',
homogeneous: true,
css: `background-color: ${hslToHex(selectedColor.hue, selectedColor.xAxis, selectedColor.yAxis / (1 + selectedColor.xAxis / 100))};`,
children: [Label({
className: 'txt txt-small',
label: 'Result',
}),],
attribute: {
update: (self) => {
self.setCss(`background-color: ${hslToHex(selectedColor.hue, selectedColor.xAxis, selectedColor.yAxis / (1 + selectedColor.xAxis / 100))};`);
self.children[0].setCss(`color: ${shouldUseBlackColor() ? colorBlack : colorWhite};`)
}
},
setup: (self) => self
.hook(selectedColor, self.attribute.update, 'sl')
.hook(selectedColor, self.attribute.update, 'hue')
.hook(selectedColor, self.attribute.update, 'assigned')
,
});
const ResultBox = ({ colorSystemName, updateCallback, copyCallback }) => Box({
children: [
Box({
vertical: true,
hexpand: true,
children: [
Label({
xalign: 0,
className: 'txt-tiny',
label: colorSystemName,
}),
Overlay({
child: Entry({
widthChars: 10,
className: 'txt-small techfont',
attribute: {
id: 0,
update: updateCallback,
},
setup: (self) => self
.hook(selectedColor, self.attribute.update, 'sl')
.hook(selectedColor, self.attribute.update, 'hue')
.hook(selectedColor, self.attribute.update, 'assigned')
// .on('activate', (self) => {
// const newColor = self.text;
// if (newColor.length != 7) return;
// selectedColor.setColorFromHex(self.text, self.attribute.id);
// })
,
}),
})
]
}),
Button({
child: MaterialIcon('content_copy', 'norm'),
onClicked: (self) => {
copyCallback(self);
self.child.label = 'done';
Utils.timeout(1000, () => self.child.label = 'content_copy');
},
setup: setupCursorHover,
})
]
});
const resultHex = ResultBox({
colorSystemName: 'Hex',
updateCallback: (self, id) => {
if (id && self.attribute.id === id) return;
self.text = hslToHex(selectedColor.hue, selectedColor.xAxis, selectedColor.yAxis / (1 + selectedColor.xAxis / 100));
},
copyCallback: () => Utils.execAsync(['wl-copy', `${hslToHex(selectedColor.hue, selectedColor.xAxis, selectedColor.yAxis / (1 + selectedColor.xAxis / 100))}`]),
})
const resultRgb = ResultBox({
colorSystemName: 'RGB',
updateCallback: (self, id) => {
if (id && self.attribute.id === id) return;
self.text = hslToRgbValues(selectedColor.hue, selectedColor.xAxis, selectedColor.yAxis / (1 + selectedColor.xAxis / 100));
},
copyCallback: () => Utils.execAsync(['wl-copy', `rgb(${hslToRgbValues(selectedColor.hue, selectedColor.xAxis, selectedColor.yAxis / (1 + selectedColor.xAxis / 100))})`]),
})
const resultHsl = ResultBox({
colorSystemName: 'HSL',
updateCallback: (self, id) => {
if (id && self.attribute.id === id) return;
self.text = `${selectedColor.hue},${selectedColor.xAxis}%,${Math.round(selectedColor.yAxis / (1 + selectedColor.xAxis / 100))}%`;
},
copyCallback: () => Utils.execAsync(['wl-copy', `hsl(${selectedColor.hue},${selectedColor.xAxis}%,${Math.round(selectedColor.yAxis / (1 + selectedColor.xAxis / 100))}%)`]),
})
const result = Box({
className: 'sidebar-module-colorpicker-result-area spacing-v-5 txt',
hexpand: true,
vertical: true,
children: [
resultColorBox,
resultHex,
resultRgb,
resultHsl,
]
})
return SidebarModule({
icon: MaterialIcon('colorize', 'norm'),
name: 'Color picker',
revealChild: false,
child: Box({
className: 'spacing-h-5',
children: [
hueSelector,
saturationAndLightnessSelector,
result,
]
})
});
}

View file

@ -0,0 +1,56 @@
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import { setupCursorHover } from '../../.widgetutils/cursorhover.js';
import { MaterialIcon } from '../../.commonwidgets/materialicon.js';
const { Box, Button, Icon, Label, Revealer } = Widget;
export default ({
icon,
name,
child,
revealChild = true,
}) => {
const headerButtonIcon = MaterialIcon(revealChild ? 'expand_less' : 'expand_more', 'norm');
const header = Button({
onClicked: () => {
content.revealChild = !content.revealChild;
headerButtonIcon.label = content.revealChild ? 'expand_less' : 'expand_more';
},
setup: setupCursorHover,
child: Box({
className: 'txt spacing-h-10',
children: [
icon,
Label({
className: 'txt-norm',
label: `${name}`,
}),
Box({
hexpand: true,
}),
Box({
className: 'sidebar-module-btn-arrow',
homogeneous: true,
children: [headerButtonIcon],
})
]
})
});
const content = Revealer({
revealChild: revealChild,
transition: 'slide_down',
transitionDuration: userOptions.animations.durationLarge,
child: Box({
className: 'margin-top-5',
homogeneous: true,
children: [child],
}),
});
return Box({
className: 'sidebar-module',
vertical: true,
children: [
header,
content,
]
});
}

View file

@ -0,0 +1,91 @@
const { Gtk } = imports.gi;
import App from 'resource:///com/github/Aylur/ags/app.js';
import Widget from 'resource:///com/github/Aylur/ags/widget.js';
import * as Utils from 'resource:///com/github/Aylur/ags/utils.js';
const { execAsync, exec } = Utils;
const { Box, Button, EventBox, Icon, Label, Scrollable } = Widget;
import SidebarModule from './module.js';
import { MaterialIcon } from '../../.commonwidgets/materialicon.js';
import { setupCursorHover } from '../../.widgetutils/cursorhover.js';
import { distroID, isArchDistro, isDebianDistro, hasFlatpak } from '../../.miscutils/system.js';
const scripts = [
{
icon: 'nixos-symbolic',
name: 'Trim system generations to 5',
command: `sudo ${App.configDir}/scripts/quickscripts/nixos-trim-generations.sh 5 0 system`,
enabled: distroID == 'nixos',
},
{
icon: 'nixos-symbolic',
name: 'Trim home manager generations to 5',
command: `${App.configDir}/scripts/quickscripts/nixos-trim-generations.sh 5 0 home-manager`,
enabled: distroID == 'nixos',
},
{
icon: 'ubuntu-symbolic',
name: 'Update packages',
command: `sudo apt update && sudo apt upgrade -y`,
enabled: isDebianDistro,
},
{
icon: 'fedora-symbolic',
name: 'Update packages',
command: `sudo dnf upgrade -y`,
enabled: distroID == 'fedora',
},
{
icon: 'arch-symbolic',
name: 'Update packages',
command: `sudo pacman -Syyu`,
enabled: isArchDistro,
},
{
icon: 'flatpak-symbolic',
name: 'Uninstall unused flatpak packages',
command: `flatpak uninstall --unused`,
enabled: hasFlatpak,
},
];
export default () => SidebarModule({
icon: MaterialIcon('code', 'norm'),
name: 'Quick scripts',
child: Box({
vertical: true,
className: 'spacing-v-5',
children: scripts.map((script) => {
if (!script.enabled) return null;
const scriptStateIcon = MaterialIcon('not_started', 'norm');
return Box({
className: 'spacing-h-5 txt',
children: [
Icon({
className: 'sidebar-module-btn-icon txt-large',
icon: script.icon,
}),
Label({
className: 'txt-small',
hpack: 'start',
hexpand: true,
label: script.name,
tooltipText: script.command,
}),
Button({
className: 'sidebar-module-scripts-button',
child: scriptStateIcon,
onClicked: () => {
App.closeWindow('sideleft');
execAsync([`bash`, `-c`, `${userOptions.apps.terminal} fish -C "${script.command}"`]).catch(print)
.then(() => {
scriptStateIcon.label = 'done';
})
},
setup: setupCursorHover,
}),
],
})
}),
})
});