🔧 chore: general improvements

- save night light filter data in `userData`
- better click detection in control center tiles
- continue development of the native polkit agent
- start night light module on shell init, drop hyprsunset scripts
This commit is contained in:
retrozinndev
2025-09-26 22:23:45 -03:00
parent 30e0f24a86
commit e1a3e654be
10 changed files with 240 additions and 93 deletions
+4
View File
@@ -35,6 +35,7 @@ import GObject, { register } from "ags/gobject";
import GLib from "gi://GLib?version=2.0"; import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0"; import Gio from "gi://Gio?version=2.0";
import Adw from "gi://Adw?version=1"; import Adw from "gi://Adw?version=1";
import { NightLight } from "./modules/nightlight";
const runnerPlugins: Array<Runner.Plugin> = [ const runnerPlugins: Array<Runner.Plugin> = [
@@ -73,6 +74,7 @@ export class Shell extends Adw.Application {
}); });
setConsoleLogDomain("colorshell"); setConsoleLogDomain("colorshell");
GLib.set_application_name("colorshell");
} }
public static getDefault(): Shell { public static getDefault(): Shell {
@@ -274,6 +276,8 @@ you should use the socket in the XDG_RUNTIME_DIR/colorshell.sock for a faster re
this.#connections.set(this, this.connect("shutdown", () => dispose())); this.#connections.set(this, this.connect("shutdown", () => dispose()));
this.#scope = getScope(); this.#scope = getScope();
NightLight.getDefault();
initPlayer(); initPlayer();
Clipboard.getDefault(); Clipboard.getDefault();
+11 -2
View File
@@ -47,13 +47,22 @@ const generalConfigDefaults = {
}; };
const userDataDefaults = { const userDataDefaults = {
/** last default adapter */
bluetooth_default_adapter: undefined,
control_center: { control_center: {
/** last default backlight */ /** last default backlight */
default_backlight: undefined default_backlight: undefined
}, },
/** last default adapter */ night_light: {
bluetooth_default_adapter: undefined /** last blue light filter temperature */
temperature: 6000,
/** last gamma filter value */
gamma: 100,
/** wheter to enable identity filters("disables" the filters) */
identity: true
}
}; };
export const userData = new Config< export const userData = new Config<
+66 -27
View File
@@ -1,7 +1,6 @@
import { execAsync } from "ags/process"; import { exec, execAsync } from "ags/process";
import { register } from "ags/gobject"; import { register } from "ags/gobject";
import { EntryPopup, EntryPopupProps } from "../widget/EntryPopup"; import { AuthPopup } from "../widget/AuthPopup";
import { AskPopup, AskPopupProps } from "../widget/AskPopup";
import AstalAuth from "gi://AstalAuth"; import AstalAuth from "gi://AstalAuth";
import Polkit from "gi://Polkit"; import Polkit from "gi://Polkit";
@@ -14,39 +13,48 @@ import GLib from "gi://GLib?version=2.0";
export class Auth extends PolkitAgent.Listener { export class Auth extends PolkitAgent.Listener {
private static instance: Auth; private static instance: Auth;
#subject: Polkit.Subject; #subject: Polkit.Subject;
#pam: AstalAuth.Pam;
#handle: any;
constructor() { constructor() {
super(); super();
this.#subject = Polkit.UnixSession.new(GLib.get_user_name()); this.#subject = Polkit.UnixSession.new(""); // TODO find how to get session id (for some reason, i can't find a session ID that works)
this.#pam = new AstalAuth.Pam();
this.register(PolkitAgent.RegisterFlags.NONE, this.#handle = this.register(
PolkitAgent.RegisterFlags.RUN_IN_THREAD,
this.#subject, this.#subject,
"/io/github/retrozinndev/Colorshell/PolicyKit/AuthAgent", "/io/github/retrozinndev/colorshell/PolicyKit/AuthAgent",
null null
); );
} }
vfunc_dispose() { vfunc_dispose() {
PolkitAgent.Listener.unregister(); PolkitAgent.Listener.unregister(this.#handle);
} }
static initiate_authentication(action_id: string, message: string, icon_name: string, details: Polkit.Details, cookie: string, identities: Array<Polkit.Identity>, cancellable?: Gio.Cancellable, callback?: Gio.AsyncReadyCallback): void | Promise<boolean> { public static initiate_authentication(action_id: string, message: string, icon_name: string, details: Polkit.Details, cookie: string, identities: Array<Polkit.Identity>, cancellable: Gio.Cancellable|null, callback: Gio.AsyncReadyCallback<Auth>|null): void {
const authPopup = EntryPopup({ const task = Gio.Task.new(
title: "Authentication", this.getDefault(),
text: message, cancellable,
isPassword: true, callback as Gio.AsyncReadyCallback|null
onFinish: callback, );
onCancel: () => cancellable?.cancel(),
closeOnAccept: false,
onAccept: (input: string) => {
if(this.validatePasswd(input)) {
authPopup.close();
}
AskPopup({
} as AskPopupProps) AuthPopup({
text: message,
iconName: icon_name,
onContinue: (data, reject, approve) => {
this.getDefault().validateAuth(data.passwd, data.user).then((success) => {
approve();
task.return_boolean(success);
}).catch((error: GLib.Error) => {
// TODO implement a number of tries (usually it's 3)
reject(`Authentication failed: ${error.message}`);
task.return_error(error);
});
} }
} as EntryPopupProps); });
} }
@@ -57,15 +65,39 @@ export class Auth extends PolkitAgent.Listener {
return this.instance; return this.instance;
} }
private static validatePasswd(passwd: string): boolean { // TODO: support fingerprint/facial auth
return AstalAuth.Pam.authenticate(passwd, null); /** @returns true if data are correct, rejects promise otherwise */
public validateAuth(passwd: string, user?: string): Promise<boolean> {
if(user !== undefined)
this.#pam.username = user;
return new Promise<boolean>((resolve, reject) => {
const connections: Array<number> = [];
connections.push(
this.#pam.connect("fail", () => {
reject(
`Auth: Authentication has failed for user ${this.#pam.username}`
);
connections.forEach(id => this.#pam.disconnect(id));
}),
this.#pam.connect("success", () => {
resolve(true);
connections.forEach(id => this.#pam.disconnect(id));
})
);
this.#pam.start_authenticate();
this.#pam.supply_secret(passwd);
});
} }
/** @returns if successful, true, or else, false */ /** @returns true if successful */
public async polkitExecute(cmd: string | Array<string>): Promise<boolean> { public async polkitExecute(cmd: string | Array<string>): Promise<boolean> {
let success: boolean = true; let success: boolean = true;
await execAsync([ "pkexec", "--", ...(Array.isArray(cmd) ? await execAsync([
cmd as Array<string> : [ cmd as string ]) ] "pkexec",
"--",
...(Array.isArray(cmd) ? cmd : [ cmd ]) ]
).catch((r) => { ).catch((r) => {
success = false; success = false;
console.error(`Polkit: Couldn't authenticate. Stderr: ${r}`); console.error(`Polkit: Couldn't authenticate. Stderr: ${r}`);
@@ -73,4 +105,11 @@ export class Auth extends PolkitAgent.Listener {
return success; return success;
} }
public static getDefault(): Auth {
if(!this.instance)
this.instance = new Auth();
return this.instance;
}
} }
+50 -28
View File
@@ -1,20 +1,22 @@
import { execAsync, exec } from "ags/process"; import { execAsync, exec } from "ags/process";
import { interval } from "ags/time"; import { userData } from "../config";
import GObject, { getter, register, setter } from "ags/gobject"; import GObject, { getter, register, setter } from "ags/gobject";
import AstalIO from "gi://AstalIO";
import GLib from "gi://GLib?version=2.0"; import GLib from "gi://GLib?version=2.0";
export { NightLight };
@register({ GTypeName: "NightLight" }) @register({ GTypeName: "NightLight" })
class NightLight extends GObject.Object { export class NightLight extends GObject.Object {
private static instance: NightLight; private static instance: NightLight;
#watchInterval: (AstalIO.Time|null) = null; public readonly maxTemperature = 20000;
#temperature: number = 4500; public readonly minTemperature = 1000;
#gamma: number = 100; public readonly identityTemperature = 6000;
public readonly maxGamma = 100;
#watchInterval: GLib.Source;
#temperature: number = this.identityTemperature;
#gamma: number = this.maxGamma;
#identity: boolean = false; #identity: boolean = false;
@getter(Number) @getter(Number)
@@ -25,11 +27,6 @@ class NightLight extends GObject.Object {
public get gamma() { return this.#gamma; } public get gamma() { return this.#gamma; }
public set gamma(newValue: number) { this.setGamma(newValue); } public set gamma(newValue: number) { this.setGamma(newValue); }
public readonly maxTemperature = 20000;
public readonly minTemperature = 1000;
public readonly identityTemperature = 6000;
public readonly maxGamma = 100;
@getter(Boolean) @getter(Boolean)
public get identity() { return this.#identity; } public get identity() { return this.#identity; }
@@ -43,7 +40,8 @@ class NightLight extends GObject.Object {
constructor() { constructor() {
super(); super();
this.#watchInterval = interval(10000, () => { this.loadData();
this.#watchInterval = setInterval(() => {
execAsync("hyprctl hyprsunset temperature").then(t => { execAsync("hyprctl hyprsunset temperature").then(t => {
if(t.trim() !== "" && t.trim().length <= 5) { if(t.trim() !== "" && t.trim().length <= 5) {
const val = Number.parseInt(t.trim()); const val = Number.parseInt(t.trim());
@@ -54,7 +52,8 @@ class NightLight extends GObject.Object {
this.notify("temperature"); this.notify("temperature");
} }
} }
}).catch((r) => console.error(r)); }).catch((r: Error) => console.error(`Night Light: Couldn't sync temperature. Stderr: ${
r.message}\n${r.stack}`));
execAsync("hyprctl hyprsunset gamma").then(g => { execAsync("hyprctl hyprsunset gamma").then(g => {
if(g.trim() !== "" && g.trim().length <= 5) { if(g.trim() !== "" && g.trim().length <= 5) {
@@ -66,11 +65,13 @@ class NightLight extends GObject.Object {
this.notify("gamma"); this.notify("gamma");
} }
} }
}).catch((r) => console.error(r)); }).catch((r: Error) => console.error(`Night Light: Couldn't sync. Stderr: ${
}); r.message}\n${r.stack}`));
}, 10000);
}
this.vfunc_dispose = () => this.#watchInterval && vfunc_dispose(): void {
this.#watchInterval.cancel(); this.#watchInterval?.destroy();
} }
public static getDefault(): NightLight { public static getDefault(): NightLight {
@@ -84,7 +85,7 @@ class NightLight extends GObject.Object {
if(value === this.temperature && !this.identity) return; if(value === this.temperature && !this.identity) return;
if(value > this.maxTemperature || value < 1000) { if(value > this.maxTemperature || value < 1000) {
console.error(`Night Light(hyprsunset): provided temperatue ${value console.error(`Night Light: provided temperatue ${value
} is out of bounds (min: 1000; max: ${this.maxTemperature})`); } is out of bounds (min: 1000; max: ${this.maxTemperature})`);
return; return;
} }
@@ -94,8 +95,8 @@ class NightLight extends GObject.Object {
this.notify("temperature"); this.notify("temperature");
this.identity = false; this.identity = false;
}).catch((r) => console.error( }).catch((r: Error) => console.error(
`Night Light(hyprsunset): Couldn't set temperature. Stderr: ${r}` `Night Light: Couldn't set temperature. Stderr: ${r.message}\n${r.stack}`
)); ));
} }
@@ -103,7 +104,7 @@ class NightLight extends GObject.Object {
if(value === this.gamma && !this.identity) return; if(value === this.gamma && !this.identity) return;
if(value > this.maxGamma || value < 0) { if(value > this.maxGamma || value < 0) {
console.error(`Night Light(hyprsunset): provided gamma ${value console.error(`Night Light: provided gamma ${value
} is out of bounds (min: 0; max: ${this.maxTemperature})`); } is out of bounds (min: 0; max: ${this.maxTemperature})`);
return; return;
} }
@@ -113,24 +114,33 @@ class NightLight extends GObject.Object {
this.notify("gamma"); this.notify("gamma");
this.identity = false; this.identity = false;
}).catch((r) => console.error( }).catch((r: Error) => console.error(
`Night Light(hyprsunset): Couldn't set gamma. Stderr: ${r}` `Night Light: Couldn't set gamma. Stderr: ${r.message}\n${r.stack}`
)); ));
} }
public applyIdentity(): void { public applyIdentity(): void {
this.dispatch("identity"); this.dispatch("identity");
if(!this.#identity) { if(!this.#identity) {
this.#identity = true; this.#identity = true;
this.notify("identity"); this.notify("identity");
} }
} }
private dispatch(call: "temperature", val: number): string;
private dispatch(call: "gamma", val: number): string;
private dispatch(call: "identity"): string;
private dispatch(call: "temperature"|"gamma"|"identity", val?: number): string { private dispatch(call: "temperature"|"gamma"|"identity", val?: number): string {
return exec(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`); return exec(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`);
} }
private async dispatchAsync(...[call, val]: Parameters<typeof this.dispatch>): Promise<string> { private async dispatchAsync(call: "temperature", val: number): Promise<string>;
private async dispatchAsync(call: "gamma", val: number): Promise<string>;
private async dispatchAsync(call: "identity"): Promise<string>;
private async dispatchAsync(call: "temperature"|"gamma"|"identity", val?: number): Promise<string> {
return await execAsync(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`); return await execAsync(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`);
} }
@@ -145,10 +155,22 @@ class NightLight extends GObject.Object {
} }
public saveData(): void { public saveData(): void {
exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/save-hyprsunset.sh`); userData.setProperty("night_light.temperature", this.#temperature);
userData.setProperty("night_light.gamma", this.#gamma);
userData.setProperty("night_light.identity", this.#identity, true);
} }
/** load temperature, gamma and identity(off/on) properties from the user configuration */
public loadData(): void { public loadData(): void {
exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/load-hyprsunset.sh`); const identity = userData.getProperty("night_light.identity", "boolean");
const temperature = userData.getProperty("night_light.temperature", "number");
const gamma = userData.getProperty("night_light.gamma", "number");
this.#temperature = temperature;
this.notify("temperature");
this.#gamma = gamma;
this.notify("gamma");
this.identity = identity;
} }
} }
+73
View File
@@ -0,0 +1,73 @@
import { Astal, Gtk } from "ags/gtk4";
import { CustomDialog, getContainerCustomDialog } from "./CustomDialog";
import GLib from "gi://GLib?version=2.0";
export type AuthPopupData = {
user: string;
hidePassword: boolean;
passwd: string;
};
export function AuthPopup(props: {
/** hide password on showup. @default true */
hidePassword?: boolean;
/** icon name of the application that's requesting this popup */
iconName?: string;
/** popup body */
text: string;
/** selected user by default */
user?: string;
/** approve data after the user clicks the "grant permission" button */
onContinue: (data: AuthPopupData, reject: (message: string) => void, approve: () => void) => void;
}): Astal.Window {
const data = {
passwd: "",
user: props.user ?? GLib.get_user_name(),
hidePassword: props.hidePassword ?? true
} satisfies AuthPopupData;
const allowUserChange = props.user === undefined;
const dialog = <CustomDialog title={"Authentication"} text={props.text}
namespace={"auth-popup"} options={[
{ text: "Deny" }, // will close and call onFinish by default
{
text: "Grant permission",
onClick: () => {
if(allowUserChange)
data.user = userEntry!.text;
data.passwd = passwordEntry.text;
data.hidePassword = passwordEntry.showPeekIcon;
props.onContinue(data,
// rejected by checker function
(m) => {
// show error to user
!messageLabel.is_visible &&
messageLabel.set_visible(true);
messageLabel.set_label(m);
// clear password entry
passwordEntry.set_text("");
},
// approved by the checker
dialog.close
);
},
closeOnClick: false
}
]}>
<Gtk.Entry class={"user"} placeholderText={"User"} visible={allowUserChange} />
<Gtk.PasswordEntry class={"password"} showPeekIcon placeholderText={"Password"} />
<Gtk.Label class={"message"} label={""} />
</CustomDialog> as Astal.Window;
const messageLabel = getContainerCustomDialog(dialog).get_last_child() as Gtk.Label;
const userEntry = allowUserChange ? getContainerCustomDialog(dialog).get_first_child() as Gtk.Entry : undefined;
const passwordEntry = getContainerCustomDialog(dialog).get_first_child()?.get_next_sibling() as Gtk.PasswordEntry;
return dialog;
}
+5 -1
View File
@@ -1,6 +1,6 @@
import { Astal, Gtk } from "ags/gtk4"; import { Astal, Gtk } from "ags/gtk4";
import { Windows } from "../windows"; import { Windows } from "../windows";
import { PopupWindow } from "./PopupWindow"; import { getPopupWindowContainer, PopupWindow } from "./PopupWindow";
import { Separator } from "./Separator"; import { Separator } from "./Separator";
import { tr } from "../i18n/intl"; import { tr } from "../i18n/intl";
import { Accessor } from "ags"; import { Accessor } from "ags";
@@ -74,3 +74,7 @@ export function CustomDialog({ options = [{ text: tr("accept") }], ...props}: Cu
return popup; return popup;
})(); })();
} }
export function getContainerCustomDialog(dialog: Astal.Window): Gtk.Box {
return getPopupWindowContainer(dialog).get_first_child()?.get_last_child()?.get_prev_sibling() as Gtk.Box;
}
@@ -1,20 +1,18 @@
import { register } from "ags/gobject"; import { register } from "ags/gobject";
import { Gtk } from "ags/gtk4"; import { Gtk } from "ags/gtk4";
import { Page } from "../Page"; import { Page } from "../Page";
import { timeout } from "ags/time";
import AstalIO from "gi://AstalIO"; import GLib from "gi://GLib?version=2.0";
export { Pages };
export type PagesProps = { export type PagesProps = {
initialPage?: Page; initialPage?: Page;
transitionDuration?: number; transitionDuration?: number;
}; };
@register({ GTypeName: "Pages" }) @register({ GTypeName: "Pages" })
class Pages extends Gtk.Box { export class Pages extends Gtk.Box {
#timeouts: Array<[AstalIO.Time, (() => void)|undefined]> = []; #timeouts: Array<[GLib.Source, (() => void)|undefined]> = [];
#page: (Page|undefined); #page: (Page|undefined);
#transDuration: number; #transDuration: number;
#transType: Gtk.RevealerTransitionType = Gtk.RevealerTransitionType.SLIDE_DOWN; #transType: Gtk.RevealerTransitionType = Gtk.RevealerTransitionType.SLIDE_DOWN;
@@ -40,7 +38,7 @@ class Pages extends Gtk.Box {
const destroyId = this.connect("destroy", () => { const destroyId = this.connect("destroy", () => {
this.disconnect(destroyId); this.disconnect(destroyId);
this.#timeouts.forEach((tmout) => { this.#timeouts.forEach((tmout) => {
tmout[0].cancel(); tmout[0].destroy();
(async () => tmout[1]?.())().catch((err: Error) => { (async () => tmout[1]?.())().catch((err: Error) => {
console.error(`${err.message}\n${err.stack}`); console.error(`${err.message}\n${err.stack}`);
}); });
@@ -89,10 +87,10 @@ class Pages extends Gtk.Box {
page.set_reveal_child(false); page.set_reveal_child(false);
this.#timeouts.push([ this.#timeouts.push([
timeout(page.transitionDuration, () => { setTimeout(() => {
this.remove(page); this.remove(page);
onClosed?.(); onClosed?.();
}), }, page.transitionDuration),
onClosed onClosed
]); ]);
} }
@@ -19,7 +19,7 @@ export const TileBluetooth = () =>
onEnabled={() => Bluetooth.getDefault().adapter?.set_powered(true)} onEnabled={() => Bluetooth.getDefault().adapter?.set_powered(true)}
onDisabled={() => Bluetooth.getDefault().adapter?.set_powered(false)} onDisabled={() => Bluetooth.getDefault().adapter?.set_powered(false)}
onClicked={() => TilesPages?.toggle(BluetoothPage)} onClicked={() => TilesPages?.toggle(BluetoothPage)}
enableOnClicked hasArrow hasArrow
state={createBinding(AstalBluetooth.get_default(), "isPowered")} state={createBinding(AstalBluetooth.get_default(), "isPowered")}
icon={createComputed([ icon={createComputed([
createBinding(AstalBluetooth.get_default(), "isPowered"), createBinding(AstalBluetooth.get_default(), "isPowered"),
@@ -23,7 +23,6 @@ export const TileNightLight = () =>
hasArrow visible={isInstalled("hyprsunset")} hasArrow visible={isInstalled("hyprsunset")}
onDisabled={() => NightLight.getDefault().identity = true} onDisabled={() => NightLight.getDefault().identity = true}
onEnabled={() => NightLight.getDefault().identity = false} onEnabled={() => NightLight.getDefault().identity = false}
enableOnClicked
onClicked={() => TilesPages?.toggle(PageNightLight)} onClicked={() => TilesPages?.toggle(PageNightLight)}
state={createBinding(NightLight.getDefault(), "identity").as(identity => !identity)} state={createBinding(NightLight.getDefault(), "identity").as(identity => !identity)}
/> />
@@ -11,7 +11,10 @@ export class Tile extends Gtk.Box {
@signal(Boolean) toggled(_state: boolean) {} @signal(Boolean) toggled(_state: boolean) {}
@signal() enabled() {} @signal() enabled() {}
@signal() disabled() {} @signal() disabled() {}
@signal() clicked() {} @signal() clicked() {
if(this.enableOnClicked)
this.enable();
}
@property(String) @property(String)
public icon: string; public icon: string;
@@ -20,7 +23,7 @@ export class Tile extends Gtk.Box {
@property(String) @property(String)
public description: string = ""; public description: string = "";
@property(Boolean) @property(Boolean)
public enableOnClicked: boolean = true; public enableOnClicked: boolean = false;
@property(Boolean) @property(Boolean)
public state: boolean = false; public state: boolean = false;
@property(Boolean) @property(Boolean)
@@ -39,6 +42,7 @@ export class Tile extends Gtk.Box {
this.state = true; this.state = true;
!this.has_css_class("enabled") && !this.has_css_class("enabled") &&
this.add_css_class("enabled"); this.add_css_class("enabled");
this.emit("toggled", true); this.emit("toggled", true);
this.emit("enabled"); this.emit("enabled");
} }
@@ -69,25 +73,34 @@ export class Tile extends Gtk.Box {
])); ]));
this.add_css_class("tile"); this.add_css_class("tile");
this.add_controller(
<Gtk.GestureClick onReleased={(_, __, px, py) => {
// gets the icon part of the tile
const { x, y, width, height } = this.get_first_child()!.get_allocation();
if((px < x || px > x+width) || (py < y || y > py+height))
this.emit("clicked");
}} /> as Gtk.GestureClick
);
this.icon = props.icon; this.icon = props.icon;
this.title = props.title; this.title = props.title;
this.hexpand = true; this.hexpand = true;
if(props.hasArrow != null) if(props.hasArrow !== undefined)
this.hasArrow = props.hasArrow; this.hasArrow = props.hasArrow;
if(props.description != null) if(props.description !== undefined)
this.description = props.description; this.description = props.description;
if(props.state != null) if(props.state !== undefined)
this.state = props.state; this.state = props.state;
if(props.enableOnClicked != null) if(props.enableOnClicked !== undefined)
this.enableOnClicked = props.enableOnClicked; this.enableOnClicked = props.enableOnClicked;
if(this.state) this.state &&
this.add_css_class("enabled"); // fix no highlight with state = true on construct this.add_css_class("enabled"); // fix no highlight when enabled on init
this.prepend( this.prepend(
<Gtk.Box hexpand={false} vexpand class={"icon"}> <Gtk.Box hexpand={false} vexpand class={"icon"}>
@@ -110,28 +123,14 @@ export class Tile extends Gtk.Box {
variableToBoolean(createBinding(this, "description")) variableToBoolean(createBinding(this, "description"))
} maxWidthChars={12} hexpand={false} } maxWidthChars={12} hexpand={false}
/> />
<Gtk.GestureClick onReleased={() => {
this.emit("clicked");
if(this.enableOnClicked && !this.state)
this.enable();
return true;
}} />
</Gtk.Box> as Gtk.Box </Gtk.Box> as Gtk.Box
); );
if(this.hasArrow) if(this.hasArrow)
this.append( this.append(
<Gtk.Image class={"arrow"} iconName={"go-next-symbolic"} halign={Gtk.Align.END}> <Gtk.Image class={"arrow"} iconName={"go-next-symbolic"}
<Gtk.GestureClick onReleased={() => { halign={Gtk.Align.END}
this.emit("clicked"); /> as Gtk.Image
if(this.enableOnClicked && !this.state)
this.enable();
return true;
}} />
</Gtk.Image> as Gtk.Image
); );
} }