Added on-screen-displays to indicate volume and brightness changes. (#34)

* Resolves #13 - Added on-screen-displays to indicate volume and brightness changes.

* <3 Aylur

* Update brightness logic for osd

* Update brightness labels

* Fixed typos in the settings menu component.

* Added options to toggle OSD and change its orientation.
This commit is contained in:
Jas Singh
2024-07-29 02:01:38 -07:00
committed by GitHub
parent f09f4ad6bd
commit 9ccc624712
16 changed files with 402 additions and 85 deletions

View File

@@ -3,6 +3,7 @@ import { Opt } from "lib/option";
export type Unit = "imperial" | "metric";
export type PowerOptions = "sleep" | "reboot" | "logout" | "shutdown";
export type NotificationAnchor = "top" | "top right" | "top left" | "bottom" | "bottom right" | "bottom left";
export type OSDAnchor = "top left" | "top" | "top right" | "right" | "bottom right" | "bottom" | "bottom left" | "left";
export type RowProps<T> = {
opt: Opt<T>
title: string
@@ -22,3 +23,5 @@ export type RowProps<T> = {
min?: number
subtitle?: string
}
export type OSDOrientation = "horizontal" | "vertical";

View File

@@ -6,6 +6,7 @@ import MenuWindows from "./modules/menus/main.js";
import SettingsDialog from "widget/settings/SettingsDialog"
import Notifications from "./modules/notifications/index.js";
import { forMonitors } from "lib/utils"
import OSD from "modules/osd/index"
App.config({
onConfigParsed: () => Utils.execAsync(`python3 ${App.configDir}/services/bluetooth.py`),
@@ -14,6 +15,7 @@ App.config({
Notifications(),
SettingsDialog(),
...forMonitors(Bar),
OSD(),
],
closeWindowDelay: {
sideright: 350,

View File

@@ -33,20 +33,20 @@ const Brightness = () => {
Widget.Slider({
vpack: "center",
vexpand: true,
value: brightness.bind("screen_value"),
value: brightness.bind("screen"),
class_name: "menu-active-slider menu-slider brightness",
draw_value: false,
hexpand: true,
min: 0,
max: 1,
onChange: ({ value }) => (brightness.screen_value = value),
onChange: ({ value }) => (brightness.screen = value),
}),
Widget.Label({
vpack: "center",
vexpand: true,
class_name: "brightness-slider-label",
label: brightness
.bind("screen_value")
.bind("screen")
.as((b) => `${Math.floor(b * 100)}%`),
}),
],

29
modules/osd/bar/index.ts Normal file
View File

@@ -0,0 +1,29 @@
import { OSDOrientation } from "lib/types/options";
import brightness from "services/Brightness"
const audio = await Service.import("audio")
export const OSDBar = (ort: OSDOrientation) => {
return Widget.Box({
class_name: "osd-bar-container",
children: [
Widget.LevelBar({
class_name: "osd-bar",
vertical: ort === "vertical",
inverted: ort === "vertical",
bar_mode: "continuous",
setup: self => {
self.hook(brightness, () => {
self.value = brightness.screen;
}, "notify::screen")
self.hook(brightness, () => {
self.value = brightness.kbd;
}, "notify::kbd")
self.hook(audio, () => {
self.toggleClassName("overflow", audio.speaker.volume > 1)
self.value = audio.speaker.volume <= 1 ? audio.speaker.volume : audio.speaker.volume - 1;
})
}
})
]
});
}

28
modules/osd/icon/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import { OSDOrientation } from "lib/types/options";
import brightness from "services/Brightness"
const audio = await Service.import("audio")
export const OSDIcon = (ort: OSDOrientation) => {
return Widget.Box({
class_name: "osd-icon-container",
hexpand: true,
child: Widget.Label({
class_name: "osd-icon",
hexpand: true,
vexpand: true,
hpack: "center",
vpack: "center",
setup: self => {
self.hook(brightness, () => {
self.label = "󱍖";
}, "notify::screen")
self.hook(brightness, () => {
self.label = "󰥻";
}, "notify::kbd")
self.hook(audio, () => {
self.label = audio.speaker.is_muted ? "󰝟" : "󰕾";
})
}
})
});
}

113
modules/osd/index.ts Normal file
View File

@@ -0,0 +1,113 @@
import { OSDAnchor } from "lib/types/options";
import options from "options";
import brightness from "services/Brightness"
import { OSDLabel } from "./label/index";
import { OSDBar } from "./bar/index";
import { OSDIcon } from "./icon/index";
const hyprland = await Service.import("hyprland");
const audio = await Service.import("audio")
const {
enable,
orientation,
location,
active_monitor,
monitor
} = options.theme.osd;
const curMonitor = Variable(monitor.value);
hyprland.active.connect("changed", () => {
curMonitor.value = hyprland.active.monitor.id;
})
const DELAY = 2500;
const getPosition = (pos: OSDAnchor): ("top" | "bottom" | "left" | "right")[] => {
const positionMap: { [key: string]: ("top" | "bottom" | "left" | "right")[] } = {
"top": ["top"],
"top right": ["top", "right"],
"top left": ["top", "left"],
"bottom": ["bottom"],
"bottom right": ["bottom", "right"],
"bottom left": ["bottom", "left"],
"right": ["right"],
"left": ["left"],
};
return positionMap[pos];
}
const renderOSD = () => {
let count = 0
const handleReveal = (self: any) => {
self.reveal_child = true
count++
Utils.timeout(DELAY, () => {
count--
if (count === 0)
self.reveal_child = false
})
}
return Widget.Revealer({
transition: "crossfade",
reveal_child: false,
setup: self => {
self.hook(brightness, () => {
handleReveal(self);
}, "notify::screen")
self.hook(brightness, () => {
handleReveal(self);
}, "notify::kbd")
self.hook(audio.speaker, () => {
handleReveal(self);
})
},
child: Widget.Box({
class_name: "osd-container",
vertical: orientation.bind("value").as(ort => ort === "vertical"),
children: orientation.bind("value").as(ort => {
if (ort === "vertical") {
return [
OSDLabel(ort),
OSDBar(ort),
OSDIcon(ort)
]
}
return [
OSDIcon(ort),
OSDBar(ort),
OSDLabel(ort),
]
})
})
})
}
export default () => Widget.Window({
monitor: Utils.merge([
curMonitor.bind("value"),
monitor.bind("value"),
active_monitor.bind("value")], (curMon, mon, activeMonitor) => {
if (activeMonitor === true) {
return curMon;
}
return mon;
}),
name: `indicator`,
visible: enable.bind("value"),
class_name: "indicator",
layer: "overlay",
anchor: location.bind("value").as(v => getPosition(v)),
click_through: true,
child: Widget.Box({
css: "padding: 1px;",
expand: true,
child: renderOSD(),
}),
})

View File

@@ -0,0 +1,30 @@
import { OSDOrientation } from "lib/types/options";
import brightness from "services/Brightness"
const audio = await Service.import("audio")
export const OSDLabel = (ort: OSDOrientation) => {
return Widget.Box({
class_name: "osd-label-container",
hexpand: true,
vexpand: true,
child: Widget.Label({
class_name: "osd-label",
hexpand: true,
vexpand: true,
hpack: "center",
vpack: "center",
setup: self => {
self.hook(brightness, () => {
self.label = `${Math.floor(brightness.screen * 100)}`;
}, "notify::screen")
self.hook(brightness, () => {
self.label = `${Math.floor(brightness.kbd * 100)}`;
}, "notify::kbd")
self.hook(audio, () => {
self.toggleClassName("overflow", audio.speaker.volume > 1)
self.label = `${Math.floor(audio.speaker.volume * 100)}`;
})
}
})
});
}

View File

@@ -53,6 +53,22 @@ const options = mkOptions(OPTIONS, {
label: opt(colors.crust)
}
},
osd: {
enable: opt(true),
orientation: opt<"horizontal" | "vertical">("vertical"),
bar_container: opt(colors.crust),
icon_container: opt(colors.lavender),
bar_color: opt(colors.lavender),
bar_empty_color: opt(colors.surface0),
bar_overflow_color: opt(colors.red),
icon: opt(colors.crust),
label: opt(colors.lavender),
monitor: opt(0),
active_monitor: opt(true),
radius: opt("0.4em"),
margins: opt("0px 5px 0px 0px"),
location: opt<"top left" | "top" | "top right" | "right" | "bottom right" | "bottom" | "bottom left" | "left">("right"),
},
bar: {
floating: opt(false),
margin_top: opt("0.5em"),
@@ -725,22 +741,6 @@ const options = mkOptions(OPTIONS, {
terminal: opt("kitty"),
osd: {
progress: {
vertical: opt(true),
pack: {
h: opt<"start" | "center" | "end">("end"),
v: opt<"start" | "center" | "end">("center"),
},
},
microphone: {
pack: {
h: opt<"start" | "center" | "end">("center"),
v: opt<"start" | "center" | "end">("end"),
},
},
},
notifications: {
position: opt<"top" | "top right" | "top left" | "bottom" | "bottom right" | "bottom left">("top right"),
timeout: opt(7000),

View File

@@ -42,5 +42,8 @@
//notifications
@import "style/notifications/popups";
//osd
@import "style/osd/index";
//settings dialog
@import "style/settings/dialog"

View File

@@ -41,5 +41,8 @@
//notifications
@import "style/notifications/popups";
//osd
@import "style/osd/index";
//settings dialog
@import "style/settings/dialog"

View File

@@ -2,9 +2,10 @@
@import '../../variables';
.menu-items.media {
background: if($bar-menus-monochrome, $bar-menus-background, $bar-menus-menu-media-background-color);
border-color: if($bar-menus-monochrome, $bar-menus-border-color, $bar-menus-menu-media-border-color);
background: if($bar-menus-monochrome, $bar-menus-background, $bar-menus-menu-media-background-color);
border-color: if($bar-menus-monochrome, $bar-menus-border-color, $bar-menus-menu-media-border-color);
}
.menu-items-container.media {
min-width: 23em;
min-height: 10em;
@@ -108,6 +109,7 @@
background: if($bar-menus-monochrome, $bar-menus-slider-primary, $bar-menus-menu-media-slider-primary);
}
}
slider {
background: if($bar-menus-monochrome, $bar-menus-slider-puck, $bar-menus-menu-media-slider-puck);
}

79
scss/style/osd/index.scss Normal file
View File

@@ -0,0 +1,79 @@
@import "../colors";
@import '../../variables';
.indicator {
.osd-container {
margin: $osd-margins;
}
.osd-label-container {
background: $osd-bar_container;
border-radius: if($osd-orientation =="vertical", $osd-radius $osd-radius 0em 0em, 0em $osd-radius $osd-radius 0em);
.osd-label {
font-size: 1em;
padding-top: if($osd-orientation =="vertical", 1em, 0em);
padding-right: if($osd-orientation =="horizontal", 1em, 0em);
color: $osd-label;
&.overflow {
color: $osd-bar_overflow_color;
}
}
}
.osd-icon-container {
background: $osd-icon_container;
border-radius: if($osd-orientation =="vertical", 0em 0em $osd-radius $osd-radius, $osd-radius 0em 0em $osd-radius );
.osd-icon {
font-size: 2em;
padding: if($osd-orientation =="vertical", 0.2em 0em, 0em 0.2em);
color: $osd-icon;
}
}
.osd-bar-container {
padding: 1.25em;
background: $osd-bar_container;
.osd-bar {
levelbar * {
transition: 200ms;
}
trough {
min-height: if($osd-orientation =="vertical", 10em, 0);
min-width: if($osd-orientation =="horizontal", 10em, 0);
}
block {
border-radius: $osd-radius/2;
&.empty {
background: $osd-bar_empty_color;
}
&.filled {
background: $osd-bar_color;
padding: 0.45em;
}
}
&.overflow {
block {
&.empty {
background: $osd-bar_color;
}
&.filled {
background: $osd-bar_overflow_color;
}
}
}
}
}
}

View File

@@ -1,84 +1,70 @@
class BrightnessService extends Service {
// every subclass of GObject.Object has to register itself
// <3 Aylur for this brightness service
import { bash, dependencies, sh } from "lib/utils"
if (!dependencies("brightnessctl"))
App.quit()
const get = (args: string) => Number(Utils.exec(`brightnessctl ${args}`))
const screen = await bash`ls -w1 /sys/class/backlight | head -1`
const kbd = await bash`ls -w1 /sys/class/leds | head -1`
class Brightness extends Service {
static {
// takes three arguments
// the class itself
// an object defining the signals
// an object defining its properties
Service.register(
this,
{
// 'name-of-signal': [type as a string from GObject.TYPE_<type>],
'screen-changed': ['float'],
},
{
// 'kebab-cased-name': [type as a string from GObject.TYPE_<type>, 'r' | 'w' | 'rw']
// 'r' means readable
// 'w' means writable
// guess what 'rw' means
'screen-value': ['float', 'rw'],
},
);
Service.register(this, {}, {
"screen": ["float", "rw"],
"kbd": ["int", "rw"],
})
}
// this Service assumes only one device with backlight
#interface = Utils.exec("sh -c 'ls -w1 /sys/class/backlight | head -1'");
#kbdMax = get(`--device ${kbd} max`)
#kbd = get(`--device ${kbd} get`)
#screenMax = get("max")
#screen = get("get") / (get("max") || 1)
// # prefix means private in JS
#screenValue = 0;
#max = Number(Utils.exec('brightnessctl max'));
get kbd() { return this.#kbd }
get screen() { return this.#screen }
// the getter has to be in snake_case
get screen_value() {
return this.#screenValue;
set kbd(value) {
if (value < 0 || value > this.#kbdMax)
return
sh(`brightnessctl -d ${kbd} s ${value} -q`).then(() => {
this.#kbd = value
this.changed("kbd")
})
}
// the setter has to be in snake_case too
set screen_value(percent) {
set screen(percent) {
if (percent < 0)
percent = 0;
percent = 0
if (percent > 1)
percent = 1;
percent = 1
Utils.execAsync(`brightnessctl set ${percent * 100}% -q`);
// the file monitor will handle the rest
sh(`brightnessctl set ${Math.floor(percent * 100)}% -q`).then(() => {
this.#screen = percent
this.changed("screen")
})
}
constructor() {
super();
super()
// setup monitor
const brightness = `/sys/class/backlight/${this.#interface}/brightness`;
Utils.monitorFile(brightness, () => this.#onChange());
const screenPath = `/sys/class/backlight/${screen}/brightness`
const kbdPath = `/sys/class/leds/${kbd}/brightness`
// initialize
this.#onChange();
}
Utils.monitorFile(screenPath, async f => {
const v = await Utils.readFileAsync(f)
this.#screen = Number(v) / this.#screenMax
this.changed("screen")
})
#onChange() {
this.#screenValue = Number(Utils.exec('brightnessctl get')) / this.#max;
// signals have to be explicitly emitted
this.emit('changed'); // emits "changed"
this.notify('screen-value'); // emits "notify::screen-value"
// or use Service.changed(propName: string) which does the above two
// this.changed('screen-value');
// emit screen-changed with the percent as a parameter
this.emit('screen-changed', this.#screenValue);
}
// overwriting the connect method, let's you
// change the default event that widgets connect to
connect(event: string = 'screen-changed', callback: any) {
return super.connect(event, callback);
Utils.monitorFile(kbdPath, async f => {
const v = await Utils.readFileAsync(f)
this.#kbd = Number(v) / this.#kbdMax
this.changed("kbd")
})
}
}
// the singleton instance
const service = new BrightnessService;
// export to use in other modules
export default service;
export default new Brightness

View File

@@ -13,6 +13,15 @@ export const BarGeneral = () => {
Option({ opt: options.theme.font.size, title: 'Font Size', type: 'string' }),
Option({ opt: options.theme.font.weight, title: 'Font Weight', subtitle: "100, 200, 300, etc.", type: 'number' }),
Option({ opt: options.terminal, title: 'Terminal', subtitle: "Tools such as 'btop' will open in this terminal", type: 'string' }),
Header('On Screen Display'),
Option({ opt: options.theme.osd.enable, title: 'Enabled', type: 'boolean' }),
Option({ opt: options.theme.osd.orientation, title: 'Orientation', type: 'enum', enums: ["horizontal", "vertical"] }),
Option({ opt: options.theme.osd.location, title: 'Position', subtitle: 'Position of the OSD on the screen', type: 'enum', enums: ["top left", "top", "top right", "right", "bottom right", "bottom", "bottom left", "left"] }),
Option({ opt: options.theme.osd.monitor, title: 'Monitor', subtitle: 'The ID of the monitor on which to display the OSD', type: 'number' }),
Option({ opt: options.theme.osd.active_monitor, title: 'Follow Cursor', subtitle: 'The OSD will follow the monitor of your cursor', type: 'boolean' }),
Option({ opt: options.theme.osd.radius, title: 'Radius', subtitle: 'Radius of the on-screen-display that indicates volume/brightness change', type: 'string' }),
Option({ opt: options.theme.osd.margins, title: 'Margins', subtitle: 'Margins in the following format: top right bottom left', type: 'string' }),
]
})
}

View File

@@ -10,10 +10,12 @@ import { NetworkMenuTheme } from "./menus/network";
import { NotificationsMenuTheme } from "./menus/notifications";
import { SystrayMenuTheme } from "./menus/systray";
import { VolumeMenuTheme } from "./menus/volume";
import { OsdTheme } from "./osd/index";
type Page = "General Settings"
| "Bar"
| "Notifications"
| "OSD"
| "Battery Menu"
| "Bluetooth Menu"
| "Clock Menu"
@@ -30,6 +32,7 @@ const pagerMap: Page[] = [
"General Settings",
"Bar",
"Notifications",
"OSD",
"Battery Menu",
"Bluetooth Menu",
"Clock Menu",
@@ -74,6 +77,7 @@ export const ThemesMenu = () => {
"General Settings": MenuTheme(),
"Bar": BarTheme(),
"Notifications": NotificationsTheme(),
"OSD": OsdTheme(),
"Battery Menu": BatteryMenuTheme(),
"Bluetooth Menu": BluetoothMenuTheme(),
"Clock Menu": ClockMenuTheme(),

View File

@@ -0,0 +1,26 @@
import { Option } from "widget/settings/shared/Option";
import { Header } from "widget/settings/shared/Header";
import options from "options";
export const OsdTheme = () => {
return Widget.Scrollable({
vscroll: "automatic",
hscroll: "never",
class_name: "osd-theme-page paged-container",
vexpand: true,
child: Widget.Box({
vertical: true,
children: [
Header('On Screen Display Settings'),
Option({ opt: options.theme.osd.bar_color, title: 'Bar', type: 'color' }),
Option({ opt: options.theme.osd.bar_overflow_color, title: 'Overflow', subtitle: 'Overflow color is for when the volume goes over a 100', type: 'color' }),
Option({ opt: options.theme.osd.bar_empty_color, title: 'Bar Background', type: 'color' }),
Option({ opt: options.theme.osd.bar_container, title: 'Bar Container Background', type: 'color' }),
Option({ opt: options.theme.osd.icon, title: 'Icon Background', type: 'color' }),
Option({ opt: options.theme.osd.icon_container, title: 'Icon Container', type: 'color' }),
Option({ opt: options.theme.osd.label, title: 'Value', type: 'color' }),
]
})
})
}