First Commit
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
/unused/
|
||||||
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
14
.idea/discord.xml
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DiscordProjectSettings">
|
||||||
|
<option name="show" value="PROJECT_FILES" />
|
||||||
|
<option name="description" value="" />
|
||||||
|
<option name="applicationTheme" value="default" />
|
||||||
|
<option name="iconsTheme" value="default" />
|
||||||
|
<option name="button1Title" value="" />
|
||||||
|
<option name="button1Url" value="" />
|
||||||
|
<option name="button2Title" value="" />
|
||||||
|
<option name="button2Url" value="" />
|
||||||
|
<option name="customApplicationId" value="" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
11
.idea/jmb-bar.iml
generated
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="EMPTY_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/jmb-bar.iml" filepath="$PROJECT_DIR$/.idea/jmb-bar.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6974
Cargo.lock
generated
Normal file
37
Cargo.toml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[package]
|
||||||
|
name = "jmb-bar"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
grass = "0.13.4"
|
||||||
|
gtk = { version = "0.11.5", package = "gtk4", features = ["v4_24"] }
|
||||||
|
gtk4-layer-shell = "0.8.1"
|
||||||
|
hyprland = { git = "https://github.com/romanstingler/hyprland-rs-next", branch = "master" }
|
||||||
|
notify = "8.2.0"
|
||||||
|
glib = "0.22.10"
|
||||||
|
mpris = "2.1.0"
|
||||||
|
time-format = "1.2.4"
|
||||||
|
nmrs = "3.5.7"
|
||||||
|
tokio = { version = "1.53.3", features = ["rt", "time", "net", "sync", "rt-multi-thread", "io-util", "macros"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
serde_json = "1.0.154"
|
||||||
|
crossbeam-channel = "0.5.17"
|
||||||
|
jmb-logger-rs = { git = "https://github.com/jmb05/jmb-logger-rs" }
|
||||||
|
sysinfo = "0.39.6"
|
||||||
|
all-smi = "0.28.1"
|
||||||
|
rustsni = "0.2.2"
|
||||||
|
thiserror = "2.0.21"
|
||||||
|
pulse = { package = "libpulse-binding", version = "2.30" }
|
||||||
|
eventfd = "0.2.0"
|
||||||
|
libc = "0.2.191"
|
||||||
|
http = "1.5.0"
|
||||||
|
hyper = { version = "1.12.0", features = ["client", "http1"] }
|
||||||
|
hyper-util = { version = "0.1.21", features = ["tokio"] }
|
||||||
|
http-body-util = "0.1.5"
|
||||||
|
brightness = { version = "0.8.0", features = ["blocking"] }
|
||||||
|
battery = "0.7.8"
|
||||||
|
ddc-hi = { version = "0.4.1", features = ["ddc-i2c"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
glib-build-tools = "0.22.8"
|
||||||
7
build.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fn main() {
|
||||||
|
glib_build_tools::compile_resources(
|
||||||
|
&["resources"],
|
||||||
|
"resources/resources.gresource.xml",
|
||||||
|
"templates.gresource",
|
||||||
|
);
|
||||||
|
}
|
||||||
37
icons/disconnected.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 640 640"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="disconnected.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="0.82067871"
|
||||||
|
inkscape:cx="218.7214"
|
||||||
|
inkscape:cy="370.42511"
|
||||||
|
inkscape:window-width="1556"
|
||||||
|
inkscape:window-height="875"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<!--!Font Awesome Free v7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
|
||||||
|
<path
|
||||||
|
d="M73 39.1C63.6 29.7 48.4 29.7 39.1 39.1C29.8 48.5 29.7 63.7 39 73.1L567 601.1C576.4 610.5 591.6 610.5 600.9 601.1C610.2 591.7 610.3 576.5 600.9 567.2L478.9 445.2C483.1 441.8 487.2 438.1 491 434.3L562.1 363.2C591.4 333.9 607.9 294.1 607.9 252.6C607.9 166.2 537.9 96.1 451.4 96.1C414.1 96.1 378.3 109.4 350.1 133.3C370.4 143.4 388.8 156.8 404.6 172.8C418.7 164.5 434.8 160.1 451.4 160.1C502.5 160.1 543.9 201.5 543.9 252.6C543.9 277.1 534.2 300.6 516.8 318L445.7 389.1C441.8 393 437.6 396.5 433.1 399.6L385.6 352.1C402.1 351.2 415.3 337.7 415.8 321C415.8 319.7 415.8 318.4 415.8 317.1C415.8 230.8 345.9 160.2 259.3 160.2C240.1 160.2 221.4 163.7 203.8 170.4L73 39.1zM257.9 224C258.5 224 259 224 259.6 224C274.7 224 289.1 227.7 301.7 234.2C303.5 235.4 305.3 236.5 307.2 237.3C334 253.6 352 283.2 352 316.9C352 317.3 352 317.7 352 318.1L257.9 224zM378.2 480L224 325.8C225.2 410.4 293.6 478.7 378.1 479.9zM171.7 273.5L126.4 228.2L77.8 276.8C48.5 306.1 32 345.9 32 387.4C32 473.8 102 543.9 188.5 543.9C225.7 543.9 261.6 530.6 289.8 506.7C269.5 496.6 251 483.2 235.2 467.2C221.2 475.4 205.1 479.8 188.5 479.8C137.4 479.8 96 438.4 96 387.3C96 362.8 105.7 339.3 123.1 321.9L171.7 273.3z"
|
||||||
|
id="path1"
|
||||||
|
style="fill:#d65d0e;fill-opacity:1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
38
icons/ethernet.svg
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="ethernet.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.2226562"
|
||||||
|
inkscape:cx="256.40895"
|
||||||
|
inkscape:cy="323.06709"
|
||||||
|
inkscape:window-width="1556"
|
||||||
|
inkscape:window-height="875"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. -->
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M0 224L0 416c0 17.7 14.3 32 32 32l32 0 0-104c0-13.3 10.7-24 24-24s24 10.7 24 24l0 104 64 0 0-104c0-13.3 10.7-24 24-24s24 10.7 24 24l0 104 64 0 0-104c0-13.3 10.7-24 24-24s24 10.7 24 24l0 104 64 0 0-104c0-13.3 10.7-24 24-24s24 10.7 24 24l0 104 32 0c17.7 0 32-14.3 32-32l0-192c0-17.7-14.3-32-32-32l-32 0 0-32c0-17.7-14.3-32-32-32l-32 0 0-32c0-17.7-14.3-32-32-32L160 64c-17.7 0-32 14.3-32 32l0 32-32 0c-17.7 0-32 14.3-32 32l0 32-32 0c-17.7 0-32 14.3-32 32z"
|
||||||
|
id="path1"
|
||||||
|
style="fill:#d65d0e;fill-opacity:1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
5
icons/gpu-card.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#d65d0e" class="bi bi-gpu-card" viewBox="0 0 16 16">
|
||||||
|
<path d="M4 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0m7.5-1.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3"/>
|
||||||
|
<path d="M0 1.5A.5.5 0 0 1 .5 1h1a.5.5 0 0 1 .5.5V4h13.5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5H2v2.5a.5.5 0 0 1-1 0V2H.5a.5.5 0 0 1-.5-.5m5.5 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5M9 8a2.5 2.5 0 1 0 5 0 2.5 2.5 0 0 0-5 0"/>
|
||||||
|
<path d="M3 12.5h3.5v1a.5.5 0 0 1-.5.5H3.5a.5.5 0 0 1-.5-.5zm4 1v-1h4v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 561 B |
1
icons/network-wireless-disabled-symbolic.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 16 16"><path fill="#2e3436" d="m1.53125.46875-1.0625 1.0625 14 14 1.0625-1.0625-6.191406-6.191406c.546875.175781 1.042968.46875 1.421875.886718l.5.542969c.175781.199219.425781.316407.691406.328125.265625.015625.523437-.078125.722656-.257812.195313-.179688.3125-.429688.324219-.695313.011719-.261719-.082031-.523437-.261719-.71875l-.5-.546875C11.117188 6.582031 9.535156 5.988281 7.96875 6c-.28125.003906-.5625.027344-.839844.066406L5.457031 4.394531c2.859375-.839843 6.183594-.222656 8.351563 1.851563l.5.476562c.398437.378906 1.03125.367188 1.414062-.03125.378906-.398437.367188-1.03125-.03125-1.410156l-.496094-.480469C13.238281 2.925781 10.617188 1.992188 8 1.992188c-1.410156 0-2.820312.273437-4.125.820312zm-.230469 3.894531c-.167969.140625-.335937.28125-.496093.4375l-.496094.480469c-.398438.378906-.410156 1.011719-.03125 1.410156.382812.398438 1.015625.410156 1.414062.03125l.5-.476562c.171875-.164063.347656-.316406.535156-.460938zm2.96875 2.964844c-.179687.148437-.347656.3125-.507812.484375l-.5.550781c-.179688.195313-.277344.453125-.261719.71875.011719.265625.128906.515625.324219.695313.199219.179687.457031.273437.722656.257812.265625-.011718.515625-.128906.691406-.328125l.5-.546875c.136719-.148437.292969-.28125.460938-.402344zm2.867188 2.871094c-.199219.09375-.386719.222656-.550781.386719-.78125.78125-.78125 2.046874 0 2.828124s2.046874.78125 2.828124 0c.164063-.164062.292969-.351562.386719-.550781zm0 0"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
5
icons/pci-symbolic.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:gpa="https://www.gtk.org/grappa" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" gpa:rendering="symbolic" width="16" height="16" gpa:version="1" gpa:state="0" gpa:state-names="outline empty"><defs id="defs1" fill="url("#gpa:foreground") rgb(0,0,0)" stroke="none" class="foreground foreground-fill" gpa:states="none" gpa:animation-repeat="0" gpa:animation-segment="0" gpa:fill="foreground">
|
||||||
|
</defs><path id="card" fill="none" stroke="url("#gpa:foreground") rgb(0,0,0)" stroke-opacity="1" stroke-width="2" stroke-linecap="butt" stroke-linejoin="round" stroke-miterlimit="4" d="M 2 3 L 13 3 C 14.1045694 3, 15 3.89543056, 15 5 L 15 11 C 15 12.1045694, 14.1045694 13, 13 13 L 12 13 L 12 15 L 5 15 L 5 13 L 2 13 M 9.99999997e-07 1 C 1.10457027 1.00000024, 2.00000024 1.8954308, 2 3 L 2 15.999999" class="transparent-fill foreground-stroke" gpa:stroke="foreground" gpa:fill="none" gpa:states="outline" gpa:stroke-minwidth="25%" gpa:stroke-maxwidth="150%">
|
||||||
|
</path><path id="detail" fill="none" stroke="url("#gpa:foreground") rgb(0,0,0)" stroke-opacity="1" stroke-width="1" stroke-linecap="butt" stroke-linejoin="round" stroke-miterlimit="4" stroke-dasharray="none" d="M 10.5 6.52252913 L 10.5 5 M 8.50000095 6.51471901 L 8.50000095 5 M 6.50000095 6.51471901 L 6.50000095 5.0083065 M 5.94924212 6.50538254 L 11.0408258 6.50538254 C 11.2946653 6.50538254, 11.4990187 6.70973635, 11.4990187 6.96357489 L 11.4990187 8.02708912 C 11.4990187 8.28092766, 11.2946653 8.48528099, 11.0408258 8.48528099 L 5.94924212 8.48528099 C 5.69540358 8.48528099, 5.49104977 8.28092766, 5.49104977 8.02708912 L 5.49104977 6.96357489 C 5.49104977 6.70973635, 5.69540358 6.50538254, 5.94924212 6.50538254 Z M 10.5 10.0078096 L 10.5 8.48528099 M 8.50000095 10 L 8.50000095 8.48528099 M 6.50000095 10 L 6.50000095 8.49358749 M 9.50000095 15 L 9.50000095 11.9921904 M 7.50000095 15 L 7.50000095 11.9921904" class="transparent-fill foreground-stroke" gpa:stroke="foreground" gpa:fill="none" gpa:states="outline" gpa:stroke-minwidth="25%" gpa:stroke-maxwidth="200%">
|
||||||
|
</path><metadata><rdf:RDF><cc:Work><cc:license rdf:resource="https://creativecommons.org/publicdomain/zero/1.0/"/><dc:subject><rdf:Bag><rdf:li>card pci extension pcb component gpu soundcard</rdf:li></rdf:Bag></dc:subject><dc:creator><cc:Agent><dc:title>Jakub Steiner</dc:title></cc:Agent></dc:creator></cc:Work></rdf:RDF></metadata></svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
4
icons/radiowaves-1-symbolic.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:gpa="https://www.gtk.org/grappa" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" gpa:rendering="symbolic" width="16" height="16" gpa:version="1" gpa:state="0" gpa:state-names="outline empty"><path d="M 8 12 L 8 12.0100002" id="path1" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" stroke="rgb(0,0,0)" fill="none" class="foreground-stroke transparent-fill" gpa:states="outline" gpa:stroke="foreground">
|
||||||
|
</path><path d="M 1 6 C 1 6, 3.5 3, 8 3 C 12.5 3, 15 6, 15 6 M 4 9 C 4 9, 5.5 7, 8 7 C 10.5 7, 12 9, 12 9" id="path3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="rgb(0,0,0)" fill="none" class="foreground-stroke transparent-fill" gpa:states="outline" gpa:stroke="foreground" gpa:stroke-minwidth="25%" gpa:stroke-maxwidth="150%">
|
||||||
|
</path><metadata><rdf:RDF><cc:Work><cc:license rdf:resource="https://creativecommons.org/publicdomain/zero/1.0/"/><dc:subject><rdf:Bag><rdf:li>network-wireless wifi network connection signal excellent strong network-wireless-signal-excellent network-wireless-signal-good radiowaves-1</rdf:li></rdf:Bag></dc:subject><dc:creator><cc:Agent><dc:title>Jakub Steiner</dc:title></cc:Agent></dc:creator></cc:Work></rdf:RDF></metadata></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
5
icons/radiowaves-2-symbolic.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:gpa="https://www.gtk.org/grappa" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" gpa:rendering="symbolic" width="16" height="16" gpa:version="1" gpa:state="0" gpa:state-names="outline empty"><path d="M 8 12 L 8 12.0100002" id="path1" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" stroke="rgb(0,0,0)" fill="none" class="foreground-stroke transparent-fill" gpa:states="outline" gpa:stroke="foreground">
|
||||||
|
</path><path d="M 4 9 C 4 9, 5.5 7, 8 7 C 10.5 7, 12 9, 12 9" id="path2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="rgb(0,0,0)" fill="none" class="foreground-stroke transparent-fill" gpa:states="outline" gpa:stroke="foreground" gpa:stroke-minwidth="25%" gpa:stroke-maxwidth="150%">
|
||||||
|
</path><path d="M 1 6 C 1 6, 3.5 3, 8 3 C 12.5 3, 15 6, 15 6" id="path3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.35" stroke="rgb(0,0,0)" fill="none" class="foreground-stroke transparent-fill" gpa:states="outline" gpa:stroke="foreground" gpa:stroke-minwidth="25%" gpa:stroke-maxwidth="150%">
|
||||||
|
</path><metadata><rdf:RDF><cc:Work><cc:license rdf:resource="https://creativecommons.org/publicdomain/zero/1.0/"/><dc:subject><rdf:Bag><rdf:li>wifi network connection signal ok network-wireless-signal-ok radiowaves-2</rdf:li></rdf:Bag></dc:subject><dc:creator><cc:Agent><dc:title>Jakub Steiner</dc:title></cc:Agent></dc:creator></cc:Work></rdf:RDF></metadata></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
4
icons/radiowaves-3-symbolic.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:gpa="https://www.gtk.org/grappa" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" gpa:rendering="symbolic" width="16" height="16" gpa:version="1" gpa:state="0" gpa:state-names="outline empty"><path d="M 8 12 L 8 12.0100002" id="path1" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" stroke="rgb(0,0,0)" fill="none" class="foreground-stroke transparent-fill" gpa:states="outline" gpa:stroke="foreground">
|
||||||
|
</path><path d="M 1 6 C 1 6, 3.5 3, 8 3 C 12.5 3, 15 6, 15 6 M 4 9 C 4 9, 5.5 7, 8 7 C 10.5 7, 12 9, 12 9" id="path3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.35" stroke="rgb(0,0,0)" fill="none" class="foreground-stroke transparent-fill" gpa:states="outline" gpa:stroke="foreground" gpa:stroke-minwidth="25%" gpa:stroke-maxwidth="150%">
|
||||||
|
</path><metadata><rdf:RDF><cc:Work><cc:license rdf:resource="https://creativecommons.org/publicdomain/zero/1.0/"/><dc:subject><rdf:Bag><rdf:li>wifi network connection signal weak poor network-wireless-signal-weak radiowaves-3</rdf:li></rdf:Bag></dc:subject><dc:creator><cc:Agent><dc:title>Jakub Steiner</dc:title></cc:Agent></dc:creator></cc:Work></rdf:RDF></metadata></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
37
icons/radiowaves-x-symbolic.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:gpa="https://www.gtk.org/grappa"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" gpa:rendering="symbolic" width="16" height="16"
|
||||||
|
gpa:version="1" gpa:state="0" gpa:state-names="outline state1 empty">
|
||||||
|
<path d="M 8 12 L 8 12.0100002" id="path1" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-opacity="1" stroke="rgb(0,0,0)" fill="none" class="foreground-stroke transparent-fill"
|
||||||
|
gpa:states="outline state1" gpa:stroke="success">
|
||||||
|
</path>
|
||||||
|
<path d="M 1 6 C 1 6, 3.5 3, 8 3 C 12.5 3, 15 6, 15 6 M 4 9 C 4 9, 5.5 7, 8 7 C 8.34540558 7, 8.67172241 7.03817749, 8.97763157 7.1039834"
|
||||||
|
id="path3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="1"
|
||||||
|
stroke="rgb(0,0,0)" fill="none" class="foreground-stroke transparent-fill" gpa:states="outline state1"
|
||||||
|
gpa:stroke="success" gpa:stroke-minwidth="25%" gpa:stroke-maxwidth="150%">
|
||||||
|
</path>
|
||||||
|
<path d="M 15 11 L 11 15 M 15 15 L 11 11" id="question" gpa:states="outline state1" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" stroke="rgb(0,0,0)" fill="none"
|
||||||
|
class="foreground-stroke transparent-fill" gpa:stroke="foreground" gpa:stroke-minwidth="25%"
|
||||||
|
gpa:stroke-maxwidth="150%">
|
||||||
|
</path>
|
||||||
|
<metadata>
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work>
|
||||||
|
<cc:license rdf:resource="https://creativecommons.org/publicdomain/zero/1.0/"/>
|
||||||
|
<dc:subject>
|
||||||
|
<rdf:Bag>
|
||||||
|
<rdf:li>wifi network network-wireless-offline</rdf:li>
|
||||||
|
</rdf:Bag>
|
||||||
|
</dc:subject>
|
||||||
|
<dc:creator>
|
||||||
|
<cc:Agent>
|
||||||
|
<dc:title>Jakub Steiner</dc:title>
|
||||||
|
</cc:Agent>
|
||||||
|
</dc:creator>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
4
icons/volume-high.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#d65d0e"
|
||||||
|
viewBox="0 0 640 640"><!--!Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
|
||||||
|
<path d="M533.6 96.5C523.3 88.1 508.2 89.7 499.8 100C491.4 110.3 493 125.4 503.3 133.8C557.5 177.8 592 244.8 592 320C592 395.2 557.5 462.2 503.3 506.3C493 514.7 491.5 529.8 499.8 540.1C508.1 550.4 523.3 551.9 533.6 543.6C598.5 490.7 640 410.2 640 320C640 229.8 598.5 149.2 533.6 96.5zM473.1 171C462.8 162.6 447.7 164.2 439.3 174.5C430.9 184.8 432.5 199.9 442.8 208.3C475.3 234.7 496 274.9 496 320C496 365.1 475.3 405.3 442.8 431.8C432.5 440.2 431 455.3 439.3 465.6C447.6 475.9 462.8 477.4 473.1 469.1C516.3 433.9 544 380.2 544 320.1C544 260 516.3 206.3 473.1 171.1zM412.6 245.5C402.3 237.1 387.2 238.7 378.8 249C370.4 259.3 372 274.4 382.3 282.8C393.1 291.6 400 305 400 320C400 335 393.1 348.4 382.3 357.3C372 365.7 370.5 380.8 378.8 391.1C387.1 401.4 402.3 402.9 412.6 394.6C434.1 376.9 448 350.1 448 320C448 289.9 434.1 263.1 412.6 245.5zM80 416L128 416L262.1 535.2C268.5 540.9 276.7 544 285.2 544C304.4 544 320 528.4 320 509.2L320 130.8C320 111.6 304.4 96 285.2 96C276.7 96 268.5 99.1 262.1 104.8L128 224L80 224C53.5 224 32 245.5 32 272L32 368C32 394.5 53.5 416 80 416z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
4
icons/volume-low.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#d65d0e"
|
||||||
|
viewBox="0 0 640 640"><!--!Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
|
||||||
|
<path d="M144 416L192 416L326.1 535.2C332.5 540.9 340.7 544 349.2 544C368.4 544 384 528.4 384 509.2L384 130.8C384 111.6 368.4 96 349.2 96C340.7 96 332.5 99.1 326.1 104.8L192 224L144 224C117.5 224 96 245.5 96 272L96 368C96 394.5 117.5 416 144 416zM476.6 245.5C466.3 237.1 451.2 238.7 442.8 249C434.4 259.3 436 274.4 446.3 282.8C457.1 291.6 464 305 464 320C464 335 457.1 348.4 446.3 357.3C436 365.7 434.5 380.8 442.8 391.1C451.1 401.4 466.3 402.9 476.6 394.6C498.1 376.9 512 350.1 512 320C512 289.9 498.1 263.1 476.5 245.5z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 769 B |
4
icons/volume-mute.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#928374"
|
||||||
|
viewBox="0 0 640 640"><!--!Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
|
||||||
|
<path d="M80 416L128 416L262.1 535.2C268.5 540.9 276.7 544 285.2 544C304.4 544 320 528.4 320 509.2L320 130.8C320 111.6 304.4 96 285.2 96C276.7 96 268.5 99.1 262.1 104.8L128 224L80 224C53.5 224 32 245.5 32 272L32 368C32 394.5 53.5 416 80 416zM399 239C389.6 248.4 389.6 263.6 399 272.9L446 319.9L399 366.9C389.6 376.3 389.6 391.5 399 400.8C408.4 410.1 423.6 410.2 432.9 400.8L479.9 353.8L526.9 400.8C536.3 410.2 551.5 410.2 560.8 400.8C570.1 391.4 570.2 376.2 560.8 366.9L513.8 319.9L560.8 272.9C570.2 263.5 570.2 248.3 560.8 239C551.4 229.7 536.2 229.6 526.9 239L479.9 286L432.9 239C423.5 229.6 408.3 229.6 399 239z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 862 B |
4
icons/volume-off.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#d65d0e"
|
||||||
|
viewBox="0 0 640 640"><!--!Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
|
||||||
|
<path d="M256 416L208 416C181.5 416 160 394.5 160 368L160 272C160 245.5 181.5 224 208 224L256 224L390.1 104.8C396.5 99.1 404.7 96 413.2 96C432.4 96 448 111.6 448 130.8L448 509.2C448 528.4 432.4 544 413.2 544C404.7 544 396.5 540.9 390.1 535.2L256 416z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 498 B |
4
icons/volume.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#d65d0e"
|
||||||
|
viewBox="0 0 640 640"><!--!Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
|
||||||
|
<path d="M112 416L160 416L294.1 535.2C300.5 540.9 308.7 544 317.2 544C336.4 544 352 528.4 352 509.2L352 130.8C352 111.6 336.4 96 317.2 96C308.7 96 300.5 99.1 294.1 104.8L160 224L112 224C85.5 224 64 245.5 64 272L64 368C64 394.5 85.5 416 112 416zM505.1 171C494.8 162.6 479.7 164.2 471.3 174.5C462.9 184.8 464.5 199.9 474.8 208.3C507.3 234.7 528 274.9 528 320C528 365.1 507.3 405.3 474.8 431.8C464.5 440.2 463 455.3 471.3 465.6C479.6 475.9 494.8 477.4 505.1 469.1C548.3 433.9 576 380.2 576 320.1C576 260 548.3 206.3 505.1 171.1zM444.6 245.5C434.3 237.1 419.2 238.7 410.8 249C402.4 259.3 404 274.4 414.3 282.8C425.1 291.6 432 305 432 320C432 335 425.1 348.4 414.3 357.3C404 365.7 402.5 380.8 410.8 391.1C419.1 401.4 434.3 402.9 444.6 394.6C466.1 376.9 480 350.1 480 320C480 289.9 466.1 263.1 444.5 245.5z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
45
icons/wifi_full.svg
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 576 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="wifi_full.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="0.95563298"
|
||||||
|
inkscape:cx="246.43352"
|
||||||
|
inkscape:cy="291.95309"
|
||||||
|
inkscape:window-width="1556"
|
||||||
|
inkscape:window-height="875"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. -->
|
||||||
|
<path
|
||||||
|
d="m 168,326.2 c -11.7,13.3 -31.9,14.5 -45.2,2.8 -13.3,-11.7 -14.5,-31.9 -2.8,-45.2 41,-46.4 101.1,-75.8 168,-75.8 66.9,0 127,29.4 168,75.8 11.7,13.3 10.4,33.5 -2.8,45.2 C 440,340.7 419.7,339.4 408,326.2 378.6,292.9 335.8,272 288,272 c -47.8,0 -90.6,20.9 -120,54.2 z"
|
||||||
|
id="path3"
|
||||||
|
style="fill:#d65d0e;fill-opacity:1" />
|
||||||
|
<path
|
||||||
|
d="m 240,432 a 48,48 0 1 1 96,0 48,48 0 1 1 -96,0 z"
|
||||||
|
id="path2"
|
||||||
|
style="fill:#d65d0e;fill-opacity:1" />
|
||||||
|
<path
|
||||||
|
d="m 288,96 c -90.9,0 -173.2,36 -233.7,94.6 -12.7,12.3 -33,12 -45.2,-0.7 -12.2,-12.7 -12,-33 0.7,-45.2 C 81.7,74.9 179.9,32 288,32 c 108.1,0 206.3,42.9 278.3,112.7 12.7,12.3 13,32.6 0.7,45.2 -12.3,12.6 -32.6,13 -45.2,0.7 C 461.2,132 378.9,96 288,96 Z"
|
||||||
|
id="path1"
|
||||||
|
style="fill:#d65d0e;fill-opacity:1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
45
icons/wifi_low.svg
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 576 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="wifi_low.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="0.95563298"
|
||||||
|
inkscape:cx="246.43352"
|
||||||
|
inkscape:cy="291.95309"
|
||||||
|
inkscape:window-width="1556"
|
||||||
|
inkscape:window-height="875"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. -->
|
||||||
|
<path
|
||||||
|
d="m 168,326.2 c -11.7,13.3 -31.9,14.5 -45.2,2.8 -13.3,-11.7 -14.5,-31.9 -2.8,-45.2 41,-46.4 101.1,-75.8 168,-75.8 66.9,0 127,29.4 168,75.8 11.7,13.3 10.4,33.5 -2.8,45.2 C 440,340.7 419.7,339.4 408,326.2 378.6,292.9 335.8,272 288,272 c -47.8,0 -90.6,20.9 -120,54.2 z"
|
||||||
|
id="path3"
|
||||||
|
style="fill:#928374;fill-opacity:1" />
|
||||||
|
<path
|
||||||
|
d="m 240,432 a 48,48 0 1 1 96,0 48,48 0 1 1 -96,0 z"
|
||||||
|
id="path2"
|
||||||
|
style="fill:#d65d0e;fill-opacity:1" />
|
||||||
|
<path
|
||||||
|
d="m 288,96 c -90.9,0 -173.2,36 -233.7,94.6 -12.7,12.3 -33,12 -45.2,-0.7 -12.2,-12.7 -12,-33 0.7,-45.2 C 81.7,74.9 179.9,32 288,32 c 108.1,0 206.3,42.9 278.3,112.7 12.7,12.3 13,32.6 0.7,45.2 -12.3,12.6 -32.6,13 -45.2,0.7 C 461.2,132 378.9,96 288,96 Z"
|
||||||
|
id="path1"
|
||||||
|
style="fill:#928374;fill-opacity:1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
45
icons/wifi_medium.svg
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 576 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="wifi_medium.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="0.95563298"
|
||||||
|
inkscape:cx="246.43352"
|
||||||
|
inkscape:cy="291.95309"
|
||||||
|
inkscape:window-width="1556"
|
||||||
|
inkscape:window-height="875"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. -->
|
||||||
|
<path
|
||||||
|
d="m 168,326.2 c -11.7,13.3 -31.9,14.5 -45.2,2.8 -13.3,-11.7 -14.5,-31.9 -2.8,-45.2 41,-46.4 101.1,-75.8 168,-75.8 66.9,0 127,29.4 168,75.8 11.7,13.3 10.4,33.5 -2.8,45.2 C 440,340.7 419.7,339.4 408,326.2 378.6,292.9 335.8,272 288,272 c -47.8,0 -90.6,20.9 -120,54.2 z"
|
||||||
|
id="path3"
|
||||||
|
style="fill:#d65d0e;fill-opacity:1" />
|
||||||
|
<path
|
||||||
|
d="m 240,432 a 48,48 0 1 1 96,0 48,48 0 1 1 -96,0 z"
|
||||||
|
id="path2"
|
||||||
|
style="fill:#d65d0e;fill-opacity:1" />
|
||||||
|
<path
|
||||||
|
d="m 288,96 c -90.9,0 -173.2,36 -233.7,94.6 -12.7,12.3 -33,12 -45.2,-0.7 -12.2,-12.7 -12,-33 0.7,-45.2 C 81.7,74.9 179.9,32 288,32 c 108.1,0 206.3,42.9 278.3,112.7 12.7,12.3 13,32.6 0.7,45.2 -12.3,12.6 -32.6,13 -45.2,0.7 C 461.2,132 378.9,96 288,96 Z"
|
||||||
|
id="path1"
|
||||||
|
style="fill:#928374;fill-opacity:1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
21
resources/expandable_bar_item.ui
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<interface>
|
||||||
|
<template class="JBExpandableBarItem" parent="GtkBox">
|
||||||
|
<property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
|
||||||
|
<property name="spacing">4</property>
|
||||||
|
<property name="css-classes">expandable-bar-item</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="main_button">
|
||||||
|
<property name="css-classes">expandable-main</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkRevealer" id="revealer">
|
||||||
|
<property name="transition-type">GTK_REVEALER_TRANSITION_TYPE_SLIDE_RIGHT</property>
|
||||||
|
<property name="transition-duration">500</property>
|
||||||
|
<property name="reveal-child">false</property>
|
||||||
|
<property name="css-classes">expandable-revealer</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</template>
|
||||||
|
</interface>
|
||||||
15
resources/icon.ui
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<interface>
|
||||||
|
<template class="JBIcon" parent="GtkBox">
|
||||||
|
<child>
|
||||||
|
<object class="GtkImage" id="image">
|
||||||
|
<property name="css-classes">icon</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="label">
|
||||||
|
<property name="css-classes">icon</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</template>
|
||||||
|
</interface>
|
||||||
37
resources/icon_slider.ui
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<interface>
|
||||||
|
<template class="JBIconSlider" parent="GtkBox">
|
||||||
|
<property name="css-classes">icon-slider</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkToggleButton" id="button">
|
||||||
|
<property name="css-classes">slider-button</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="icon_box">
|
||||||
|
<property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
|
||||||
|
<child>
|
||||||
|
<object class="JBIcon" id="main_icon"/>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="JBIcon" id="second_icon">
|
||||||
|
<property name="visible">false</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkAdjustment" id="adjustment">
|
||||||
|
<property name="lower">0</property>
|
||||||
|
<property name="upper">100</property>
|
||||||
|
<property name="step-increment">1</property>
|
||||||
|
<property name="page-increment">5</property>
|
||||||
|
<property name="value">60</property>
|
||||||
|
</object>
|
||||||
|
<object class="GtkScale" id="slider">
|
||||||
|
<property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
|
||||||
|
<property name="adjustment">adjustment</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</template>
|
||||||
|
</interface>
|
||||||
10
resources/resources.gresource.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<gresources>
|
||||||
|
<gresource prefix="/net/jmb19905/jmb-bar/">
|
||||||
|
<file compressed="true" preprocess="xml-stripblanks">utilities.ui</file>
|
||||||
|
<file compressed="true" preprocess="xml-stripblanks">icon.ui</file>
|
||||||
|
<file compressed="true" preprocess="xml-stripblanks">wifi_widget.ui</file>
|
||||||
|
<file compressed="true" preprocess="xml-stripblanks">icon_slider.ui</file>
|
||||||
|
<file compressed="true" preprocess="xml-stripblanks">expandable_bar_item.ui</file>
|
||||||
|
</gresource>
|
||||||
|
</gresources>
|
||||||
164
resources/utilities.ui
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<interface>
|
||||||
|
<template class="JBUtilities" parent="GtkBox">
|
||||||
|
<property name="halign">GTK_ALIGN_END</property>
|
||||||
|
<property name="valign">GTK_ALIGN_FILL</property>
|
||||||
|
<property name="hexpand">true</property>
|
||||||
|
<property name="vexpand">true</property>
|
||||||
|
<property name="css-classes">utilities</property>
|
||||||
|
<child>
|
||||||
|
<object class="JBIconSlider" id="volume">
|
||||||
|
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="JBIconSlider" id="brightness">
|
||||||
|
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkToggleButton" id="caffeine">
|
||||||
|
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="JBExpandableBarItem" id="stats">
|
||||||
|
<property name="spacing">0</property>
|
||||||
|
<property name="vexpand">true</property>
|
||||||
|
<property name="valign">GTK_ALIGN_FILL</property>
|
||||||
|
<property name="css-classes">stats</property>
|
||||||
|
<property name="main-child">
|
||||||
|
<object class="JBIconLabel" id="cpu">
|
||||||
|
<property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<property name="font-icon"></property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="second-child">
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
|
||||||
|
<child>
|
||||||
|
<object class="JBIconLabel" id="ram">
|
||||||
|
<property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<property name="font-icon"></property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="JBIconLabel" id="disk">
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<property name="font-icon"></property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="JBIconLabel" id="gpu">
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<property name="icon-path">icons/gpu-card.svg</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="network">
|
||||||
|
<child>
|
||||||
|
<object class="JBIconLabel">
|
||||||
|
<property name="spacing">0</property>
|
||||||
|
<property name="icon-path">icons/disconnected.svg</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="vpn_button">
|
||||||
|
<child>
|
||||||
|
<object class="JBIcon">
|
||||||
|
<property name="font-icon"></property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkRevealer" id="vpns">
|
||||||
|
<property name="transition-type">GTK_REVEALER_TRANSITION_TYPE_SLIDE_RIGHT</property>
|
||||||
|
<property name="transition-duration">500</property>
|
||||||
|
<property name="reveal-child">false</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="spacing">0</property>
|
||||||
|
<property name="vexpand">true</property>
|
||||||
|
<property name="valign">GTK_ALIGN_FILL</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="wg">
|
||||||
|
<property name="css-classes">vpn</property>
|
||||||
|
<property name="label">wg</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="ts">
|
||||||
|
<property name="css-classes">vpn</property>
|
||||||
|
<property name="label">ts</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="JBExpandableBarItem" id="battery">
|
||||||
|
<property name="spacing">0</property>
|
||||||
|
<property name="vexpand">true</property>
|
||||||
|
<property name="valign">GTK_ALIGN_FILL</property>
|
||||||
|
<property name="css-classes">battery</property>
|
||||||
|
<property name="main-child">
|
||||||
|
<object class="JBIconLabel" id="capacity">
|
||||||
|
<property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
|
||||||
|
<property name="spacing">0</property>
|
||||||
|
<property name="font-icon">none</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
<property name="second-child">
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="spacing">0</property>
|
||||||
|
<property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
|
||||||
|
<child>
|
||||||
|
<object class="JBIconLabel" id="wattage">
|
||||||
|
<property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
|
||||||
|
<property name="spacing">0</property>
|
||||||
|
<property name="font-icon"></property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="JBIconLabel" id="remaining_time">
|
||||||
|
<property name="spacing">0</property>
|
||||||
|
<property name="font-icon"></property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="JBExpandableBarItem" id="clock">
|
||||||
|
<property name="spacing">0</property>
|
||||||
|
<property name="vexpand">true</property>
|
||||||
|
<property name="valign">GTK_ALIGN_FILL</property>
|
||||||
|
<property name="css-classes">clock</property>
|
||||||
|
<property name="second-child">
|
||||||
|
<object class="GtkLabel" id="time_hidden"/>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="shutdown">
|
||||||
|
<child>
|
||||||
|
<object class="JBIcon">
|
||||||
|
<property name="font-icon"></property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</template>
|
||||||
|
</interface>
|
||||||
57
resources/wifi_widget.ui
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<interface>
|
||||||
|
<template class="JBWifiWidget" parent="GtkButton">
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
|
||||||
|
<property name="spacing">4</property>
|
||||||
|
<child>
|
||||||
|
<object class="JBIcon" id="wifi_icon">
|
||||||
|
<property name="css-classes">wifi-icon</property>
|
||||||
|
<property name="pixel-size">24</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkSpinner" id="spinner">
|
||||||
|
<property name="css-classes">wifi-spinner</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkStack" id="stack">
|
||||||
|
<child>
|
||||||
|
<object class="GtkStackPage">
|
||||||
|
<property name="name">ssid</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkLabel" id="ssid">
|
||||||
|
<property name="hexpand">true</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkStackPage">
|
||||||
|
<property name="name">pass</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkPasswordEntry" id="password">
|
||||||
|
<property name="hexpand">true</property>
|
||||||
|
<property name="css-classes">password</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="JBIcon" id="known">
|
||||||
|
<property name="pixel-size">24</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="JBIcon" id="secure">
|
||||||
|
<property name="pixel-size">24</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</template>
|
||||||
|
</interface>
|
||||||
166
src/backend.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
pub mod audio;
|
||||||
|
pub mod brightness_control;
|
||||||
|
pub mod network;
|
||||||
|
pub mod perf_stats;
|
||||||
|
pub(crate) mod tailscale;
|
||||||
|
pub mod window_manager_hypr;
|
||||||
|
|
||||||
|
use crate::LOGGER;
|
||||||
|
use crate::backend::audio::{MediaBackend, MediaCommand};
|
||||||
|
use crate::backend::brightness_control::{BrightnessManager, BrightnessMessage};
|
||||||
|
use crate::backend::network::{NetworkBackendHandle, NetworkCommand, NetworkState};
|
||||||
|
use crate::backend::perf_stats::{Stats, StatsBackend};
|
||||||
|
use crate::backend::tailscale::{TailscaleBackend, TailscaleState};
|
||||||
|
use crate::backend::window_manager_hypr::{HyprlandComms, WindowManagerEvent};
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use std::fmt::Debug;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
|
pub(crate) type Registrator<M> = Sender<Sender<M>>;
|
||||||
|
|
||||||
|
pub trait BackendThread<M: Debug + Clone, D> {
|
||||||
|
fn subscribe(registrator: Registrator<M>) -> (Receiver<M>, D);
|
||||||
|
fn subscribers(&self) -> &Vec<Sender<M>>;
|
||||||
|
fn init(registration_rx: Receiver<Sender<M>>);
|
||||||
|
fn publish(&self, message: M) {
|
||||||
|
trace!(&LOGGER, "Sending: {message:?} to {:?}", self.subscribers());
|
||||||
|
for tx in self.subscribers() {
|
||||||
|
if let Err(e) = tx.send(message.clone()) {
|
||||||
|
warn!(
|
||||||
|
&LOGGER,
|
||||||
|
"Error sending message to frontend: {}",
|
||||||
|
e.to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn start() -> Registrator<M> {
|
||||||
|
let (registration_tx, registration_rx) = crossbeam_channel::unbounded::<Sender<M>>();
|
||||||
|
Self::init(registration_rx);
|
||||||
|
registration_tx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Backend {
|
||||||
|
hypr_registrator: Registrator<WindowManagerEvent>,
|
||||||
|
net_registrator: Registrator<NetworkState>,
|
||||||
|
br_registrator: Registrator<BrightnessMessage>,
|
||||||
|
stats_registrator: Registrator<Stats>,
|
||||||
|
media_registrator: Registrator<MediaCommand>,
|
||||||
|
tailscale_registrator: Registrator<TailscaleState>,
|
||||||
|
hypridle: Arc<Mutex<Option<Child>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
pub fn start() -> Self {
|
||||||
|
debug!(&LOGGER, "Starting Backend threads...");
|
||||||
|
let hypr_registrator = HyprlandComms::start();
|
||||||
|
let net_registrator = NetworkBackendHandle::start();
|
||||||
|
let br_registrator = BrightnessManager::start();
|
||||||
|
let stats_registrator = StatsBackend::start();
|
||||||
|
let media_registrator = MediaBackend::start();
|
||||||
|
let tailscale_registrator = TailscaleBackend::start();
|
||||||
|
let hypridle = Arc::new(Mutex::new(
|
||||||
|
Command::new("hypridle").stdout(Stdio::null()).spawn().ok(),
|
||||||
|
));
|
||||||
|
debug!(&LOGGER, "Started Backend threads");
|
||||||
|
Self {
|
||||||
|
hypr_registrator,
|
||||||
|
net_registrator,
|
||||||
|
br_registrator,
|
||||||
|
stats_registrator,
|
||||||
|
media_registrator,
|
||||||
|
tailscale_registrator,
|
||||||
|
hypridle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&self) -> BackendChannels {
|
||||||
|
let (net_rx, net_tx) = NetworkBackendHandle::subscribe(self.net_registrator.clone());
|
||||||
|
let (hypr_rx, _) = HyprlandComms::subscribe(self.hypr_registrator.clone());
|
||||||
|
let (br_rx, br_tx) = BrightnessManager::subscribe(self.br_registrator.clone());
|
||||||
|
let (stats_rx, _) = StatsBackend::subscribe(self.stats_registrator.clone());
|
||||||
|
let (media_rx, media_tx) = MediaBackend::subscribe(self.media_registrator.clone());
|
||||||
|
let (tailscale_rx, _) = TailscaleBackend::subscribe(self.tailscale_registrator.clone());
|
||||||
|
BackendChannels {
|
||||||
|
net_rx: Some(net_rx),
|
||||||
|
net_tx,
|
||||||
|
hypr_rx: Some(hypr_rx),
|
||||||
|
br_rx: Some(br_rx),
|
||||||
|
br_tx,
|
||||||
|
stats_rx: Some(stats_rx),
|
||||||
|
media_rx: Some(media_rx),
|
||||||
|
media_tx,
|
||||||
|
tailscale_rx: Some(tailscale_rx),
|
||||||
|
hypridle: self.hypridle.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_drop_guard(&self) -> DropGuard {
|
||||||
|
DropGuard(self.hypridle.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BackendChannels {
|
||||||
|
net_rx: Option<Receiver<NetworkState>>,
|
||||||
|
net_tx: UnboundedSender<NetworkCommand>,
|
||||||
|
hypr_rx: Option<Receiver<WindowManagerEvent>>,
|
||||||
|
br_rx: Option<Receiver<BrightnessMessage>>,
|
||||||
|
br_tx: Sender<BrightnessMessage>,
|
||||||
|
stats_rx: Option<Receiver<Stats>>,
|
||||||
|
media_rx: Option<Receiver<MediaCommand>>,
|
||||||
|
media_tx: Sender<MediaCommand>,
|
||||||
|
tailscale_rx: Option<Receiver<TailscaleState>>,
|
||||||
|
hypridle: Arc<Mutex<Option<Child>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendChannels {
|
||||||
|
pub fn take_net_rx(&mut self) -> Option<Receiver<NetworkState>> {
|
||||||
|
self.net_rx.take()
|
||||||
|
}
|
||||||
|
pub fn take_hypr_rx(&mut self) -> Option<Receiver<WindowManagerEvent>> {
|
||||||
|
self.hypr_rx.take()
|
||||||
|
}
|
||||||
|
pub fn take_br_rx(&mut self) -> Option<Receiver<BrightnessMessage>> {
|
||||||
|
self.br_rx.take()
|
||||||
|
}
|
||||||
|
pub fn take_stats_rx(&mut self) -> Option<Receiver<Stats>> {
|
||||||
|
self.stats_rx.take()
|
||||||
|
}
|
||||||
|
pub fn take_ts_rx(&mut self) -> Option<Receiver<TailscaleState>> {
|
||||||
|
self.tailscale_rx.take()
|
||||||
|
}
|
||||||
|
pub fn take_media_rx(&mut self) -> Option<Receiver<MediaCommand>> {
|
||||||
|
self.media_rx.take()
|
||||||
|
}
|
||||||
|
pub fn net_tx(&self) -> UnboundedSender<NetworkCommand> {
|
||||||
|
self.net_tx.clone()
|
||||||
|
}
|
||||||
|
pub fn br_tx(&self) -> Sender<BrightnessMessage> {
|
||||||
|
self.br_tx.clone()
|
||||||
|
}
|
||||||
|
pub fn media_tx(&self) -> Sender<MediaCommand> {
|
||||||
|
self.media_tx.clone()
|
||||||
|
}
|
||||||
|
pub fn hypridle(&self) -> Arc<Mutex<Option<Child>>> {
|
||||||
|
self.hypridle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DropGuard(Arc<Mutex<Option<Child>>>);
|
||||||
|
impl Drop for DropGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut hypridle = self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock on hypridle child process handle");
|
||||||
|
if hypridle.is_some() {
|
||||||
|
info!(&LOGGER, "Killing hypridle");
|
||||||
|
let _ = hypridle.as_mut().unwrap().kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
423
src/backend/audio.rs
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
use crate::LOGGER;
|
||||||
|
use crate::backend::audio::ControllerMsg::Request;
|
||||||
|
use crate::backend::{BackendThread, Registrator};
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
use eventfd::{EfdFlags, EventFD};
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use mainloop::standard::Mainloop;
|
||||||
|
use pulse::callbacks::ListResult;
|
||||||
|
use pulse::context::subscribe::{Facility, InterestMaskSet};
|
||||||
|
use pulse::context::{Context, FlagSet, State};
|
||||||
|
use pulse::mainloop::api;
|
||||||
|
use pulse::mainloop::events::io;
|
||||||
|
use pulse::proplist::Proplist;
|
||||||
|
use pulse::volume::{ChannelVolumes, Volume};
|
||||||
|
use pulse::{mainloop, operation};
|
||||||
|
use std::boxed::Box;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::os::fd::{AsRawFd, RawFd};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
pub struct AudioController {
|
||||||
|
pub mainloop: Mainloop,
|
||||||
|
context: Context,
|
||||||
|
device_index: Option<u32>,
|
||||||
|
device_bt: Option<bool>,
|
||||||
|
current_vol: u8,
|
||||||
|
current_mute: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ControllerMsg {
|
||||||
|
NotifyServer,
|
||||||
|
NotifySink(u32),
|
||||||
|
Request,
|
||||||
|
Set(u8, bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioController {
|
||||||
|
pub fn new(tx: Sender<ControllerMsg>) -> Self {
|
||||||
|
let mut mainloop = Mainloop::new().unwrap();
|
||||||
|
let mut prop_list = Proplist::new().unwrap();
|
||||||
|
|
||||||
|
prop_list
|
||||||
|
.set_str(pulse::proplist::properties::APPLICATION_NAME, "jmb-bar")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut context = Context::new_with_proplist(&mainloop, "jmb-bar", &prop_list).unwrap();
|
||||||
|
|
||||||
|
context.connect(None, FlagSet::NOFLAGS, None).unwrap();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match context.get_state() {
|
||||||
|
State::Ready => break,
|
||||||
|
State::Failed | State::Terminated => panic!("PulseAudio connection failed"),
|
||||||
|
_ => mainloop.iterate(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_list = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let clone = Arc::clone(&device_list);
|
||||||
|
let op = context
|
||||||
|
.introspect()
|
||||||
|
.get_sink_info_list(move |info| match info {
|
||||||
|
ListResult::Item(device) => {
|
||||||
|
if let Some(description) = device.description.as_ref()
|
||||||
|
&& let Some(name) = device.name.as_ref()
|
||||||
|
{
|
||||||
|
if let Ok(mut lock) = clone.lock() {
|
||||||
|
lock.push((name.to_string(), description.to_string()));
|
||||||
|
} else {
|
||||||
|
error!(&LOGGER, "Failed to unlock device list on device {}", name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!(&LOGGER, "Failed to access device description");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ListResult::End => {}
|
||||||
|
ListResult::Error => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
while op.get_state() == operation::State::Running {
|
||||||
|
mainloop.iterate(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.set_subscribe_callback(Some(Box::new(move |facility, _, index| {
|
||||||
|
if facility.is_some_and(|f| f == Facility::Sink) {
|
||||||
|
let _ = tx.send(ControllerMsg::NotifySink(index));
|
||||||
|
} else if facility.is_some_and(|f| f == Facility::Server) {
|
||||||
|
let _ = tx.send(ControllerMsg::NotifyServer);
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
let _op = context.subscribe(InterestMaskSet::ALL, |success| {
|
||||||
|
trace!(&LOGGER, "Subscribed to pulseaudio events: {success}");
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut controller = AudioController {
|
||||||
|
context,
|
||||||
|
mainloop,
|
||||||
|
device_index: None,
|
||||||
|
device_bt: None,
|
||||||
|
current_vol: 0,
|
||||||
|
current_mute: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.query_default_device_blocking();
|
||||||
|
controller
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_default_device_blocking(&mut self) {
|
||||||
|
let default_device = Arc::new(Mutex::new(None));
|
||||||
|
let dev_clone = default_device.clone();
|
||||||
|
let op = self.context.introspect().get_server_info(move |info| {
|
||||||
|
if let Some(name) = info.default_sink_name.as_ref() {
|
||||||
|
let _ = dev_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock on default dev")
|
||||||
|
.insert(name.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
while op.get_state() == operation::State::Running {
|
||||||
|
self.mainloop.iterate(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = default_device
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock on device name")
|
||||||
|
.take()
|
||||||
|
.unwrap_or(String::new());
|
||||||
|
|
||||||
|
let index = Arc::new(Mutex::new(None));
|
||||||
|
let index_clone = index.clone();
|
||||||
|
let op = self
|
||||||
|
.context
|
||||||
|
.introspect()
|
||||||
|
.get_sink_info_by_name(&name, move |res| match res {
|
||||||
|
ListResult::Item(item) => {
|
||||||
|
let _ = index_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock on device index")
|
||||||
|
.insert(item.index);
|
||||||
|
}
|
||||||
|
ListResult::End => {}
|
||||||
|
ListResult::Error => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
while op.get_state() == operation::State::Running {
|
||||||
|
self.mainloop.iterate(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let val = index
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock on device index")
|
||||||
|
.take();
|
||||||
|
|
||||||
|
self.device_index = val;
|
||||||
|
self.device_bt = Some(name.starts_with("bluez"));
|
||||||
|
debug!(
|
||||||
|
&LOGGER,
|
||||||
|
"Found default Audio Sink Idx: {:?} Bluetooth: {}",
|
||||||
|
self.device_index,
|
||||||
|
self.device_bt.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_state_blocking(&mut self) -> bool {
|
||||||
|
if let Some(idx) = self.device_index {
|
||||||
|
let volume = Rc::new(Cell::new(self.current_vol));
|
||||||
|
let vol_clone = volume.clone();
|
||||||
|
let mute = Rc::new(Cell::new(self.current_mute));
|
||||||
|
let mute_clone = mute.clone();
|
||||||
|
let op =
|
||||||
|
self.context
|
||||||
|
.introspect()
|
||||||
|
.get_sink_info_by_index(idx, move |info| match info {
|
||||||
|
ListResult::Item(info) => {
|
||||||
|
let percent =
|
||||||
|
info.volume.avg().0 as f64 * 100.0 / Volume::NORMAL.0 as f64;
|
||||||
|
trace!(
|
||||||
|
&LOGGER,
|
||||||
|
"State of default Sink changed: {percent} {}", info.mute
|
||||||
|
);
|
||||||
|
vol_clone.set(percent as u8);
|
||||||
|
mute_clone.set(info.mute);
|
||||||
|
}
|
||||||
|
ListResult::End => {}
|
||||||
|
ListResult::Error => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
while op.get_state() == operation::State::Running {
|
||||||
|
self.mainloop.iterate(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dirty = false;
|
||||||
|
let volume = volume.take();
|
||||||
|
if self.current_vol != volume {
|
||||||
|
self.current_vol = volume;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
let mute = mute.take();
|
||||||
|
if self.current_mute != mute {
|
||||||
|
self.current_mute = mute;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_state(&mut self, volume: u8, mute: bool) {
|
||||||
|
if let Some(idx) = self.device_index {
|
||||||
|
if self.current_vol != volume {
|
||||||
|
self.current_vol = volume;
|
||||||
|
let volume = Volume(
|
||||||
|
((volume.min(150) as f64 / 100.0) * Volume::NORMAL.0 as f64).round() as u32,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut channel_volumes = ChannelVolumes::default();
|
||||||
|
channel_volumes.set(2, volume);
|
||||||
|
|
||||||
|
self.context.introspect().set_sink_volume_by_index(
|
||||||
|
idx,
|
||||||
|
&channel_volumes,
|
||||||
|
Some(Box::new(|success| {
|
||||||
|
if !success {
|
||||||
|
warn!(&LOGGER, "Failed setting sink volume");
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.current_mute != mute {
|
||||||
|
self.current_mute = mute;
|
||||||
|
let a = self.context.introspect().set_sink_mute_by_index(
|
||||||
|
idx,
|
||||||
|
mute,
|
||||||
|
Some(Box::new(|success| {
|
||||||
|
if !success {
|
||||||
|
warn!(&LOGGER, "Failed setting sink mute");
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
while a.get_state() == operation::State::Running {
|
||||||
|
self.mainloop.iterate(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum MediaCommand {
|
||||||
|
Init(Sender<MediaCommand>),
|
||||||
|
Request,
|
||||||
|
Cmd(u8, bool, bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MediaBackend {
|
||||||
|
subscribers: Vec<Sender<MediaCommand>>,
|
||||||
|
command_tx: Sender<MediaCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendThread<MediaCommand, Sender<MediaCommand>> for MediaBackend {
|
||||||
|
fn subscribe(
|
||||||
|
registrator: Registrator<MediaCommand>,
|
||||||
|
) -> (Receiver<MediaCommand>, Sender<MediaCommand>) {
|
||||||
|
let (result_tx, result_rx) = crossbeam_channel::unbounded::<MediaCommand>();
|
||||||
|
let _ = registrator.send(result_tx);
|
||||||
|
while let Ok(state) = result_rx.recv() {
|
||||||
|
return match state {
|
||||||
|
MediaCommand::Init(sender) => (result_rx, sender),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
panic!("Channel closed unexpectedly, without returning anything")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribers(&self) -> &Vec<Sender<MediaCommand>> {
|
||||||
|
&self.subscribers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(registration_rx: Receiver<Sender<MediaCommand>>) {
|
||||||
|
let (command_tx, command_rx) = crossbeam_channel::unbounded::<MediaCommand>();
|
||||||
|
let instance = Arc::new(Mutex::new(Self {
|
||||||
|
subscribers: vec![],
|
||||||
|
command_tx,
|
||||||
|
}));
|
||||||
|
let instance_clone = instance.clone();
|
||||||
|
let (controller_tx, controller_rx) = crossbeam_channel::unbounded::<ControllerMsg>();
|
||||||
|
let controller_tx_clone = controller_tx.clone();
|
||||||
|
let notify = Arc::new(EventFD::new(0, EfdFlags::EFD_CLOEXEC).unwrap());
|
||||||
|
let notify_clone = notify.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut controller = AudioController::new(controller_tx_clone);
|
||||||
|
let _event = api::Mainloop::new_io_event(
|
||||||
|
&mut controller.mainloop,
|
||||||
|
notify_clone.as_raw_fd(),
|
||||||
|
io::FlagSet::INPUT,
|
||||||
|
Box::new(move |_, fd, _flags| match drain_eventfd(fd) {
|
||||||
|
Ok(_) => trace!(&LOGGER, "Woke pulseaudio loop using EventFD"),
|
||||||
|
Err(e) => warn!(&LOGGER, "Error draining EventFD {}", e.to_string()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
loop {
|
||||||
|
while let Ok(msg) = controller_rx.try_recv() {
|
||||||
|
match msg {
|
||||||
|
ControllerMsg::Request => {
|
||||||
|
let _ = instance_clone.lock().expect("Couldn't get lock").publish(
|
||||||
|
MediaCommand::Cmd(
|
||||||
|
controller.current_vol,
|
||||||
|
controller.current_mute,
|
||||||
|
controller.device_bt.unwrap_or(false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ControllerMsg::NotifyServer => {
|
||||||
|
controller.query_default_device_blocking();
|
||||||
|
let _ = controller.query_state_blocking();
|
||||||
|
let _ = instance_clone.lock().expect("Couldn't get lock").publish(
|
||||||
|
MediaCommand::Cmd(
|
||||||
|
controller.current_vol,
|
||||||
|
controller.current_mute,
|
||||||
|
controller.device_bt.unwrap_or(false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ControllerMsg::NotifySink(idx) => {
|
||||||
|
if controller.device_index.is_some_and(|i| i == idx) {
|
||||||
|
let changed = controller.query_state_blocking();
|
||||||
|
if changed {
|
||||||
|
let _ = instance_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock")
|
||||||
|
.publish(MediaCommand::Cmd(
|
||||||
|
controller.current_vol,
|
||||||
|
controller.current_mute,
|
||||||
|
controller.device_bt.unwrap_or(false),
|
||||||
|
));
|
||||||
|
trace!(
|
||||||
|
&LOGGER,
|
||||||
|
"Change: {} muted: {}",
|
||||||
|
controller.current_vol,
|
||||||
|
controller.current_mute
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ControllerMsg::Set(volume, mute) => {
|
||||||
|
controller.set_state(volume, mute);
|
||||||
|
let _ = instance_clone.lock().expect("Couldn't get lock").publish(
|
||||||
|
MediaCommand::Cmd(
|
||||||
|
controller.current_vol,
|
||||||
|
controller.current_mute,
|
||||||
|
controller.device_bt.unwrap_or(false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.mainloop.iterate(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let instance_clone = instance.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
while let Ok(sender) = registration_rx.recv() {
|
||||||
|
if sender
|
||||||
|
.send(MediaCommand::Init(
|
||||||
|
instance_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock")
|
||||||
|
.command_tx
|
||||||
|
.clone(),
|
||||||
|
))
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let _ = sender.send(MediaCommand::Request);
|
||||||
|
instance_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get subscribers")
|
||||||
|
.subscribers
|
||||||
|
.push(sender);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
thread::spawn(move || {
|
||||||
|
while let Ok(command) = command_rx.recv() {
|
||||||
|
match command {
|
||||||
|
MediaCommand::Cmd(vol, mute, _) => {
|
||||||
|
let _ = controller_tx.send(ControllerMsg::Set(vol, mute));
|
||||||
|
notify.write(1).expect("Error writing notify");
|
||||||
|
}
|
||||||
|
MediaCommand::Request => {
|
||||||
|
let _ = controller_tx.send(Request);
|
||||||
|
notify.write(1).expect("Error writing notify");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!(&LOGGER, "Frontend sent invalid Command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_eventfd(fd: RawFd) -> std::io::Result<u64> {
|
||||||
|
let mut value: u64 = 0;
|
||||||
|
|
||||||
|
let result = unsafe { libc::read(fd, (&mut value as *mut u64).cast(), size_of::<u64>()) };
|
||||||
|
|
||||||
|
if result == size_of::<u64>() as isize {
|
||||||
|
Ok(value)
|
||||||
|
} else if result == -1 {
|
||||||
|
Err(std::io::Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::UnexpectedEof,
|
||||||
|
"short eventfd read",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
323
src/backend/brightness_control.rs
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
use crate::LOGGER;
|
||||||
|
use crate::backend::{BackendThread, Registrator};
|
||||||
|
use brightness::blocking::{Brightness, BrightnessDevice};
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
use ddc_hi::Ddc;
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::os::unix::prelude::MetadataExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::{fs, thread};
|
||||||
|
|
||||||
|
pub enum DisplayBackend {
|
||||||
|
Sysfs(BrightnessDevice),
|
||||||
|
DdcCi(ddc_hi::Display),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_sysfs_dev(id: &str) -> Option<BrightnessDevice> {
|
||||||
|
brightness::blocking::brightness_devices()
|
||||||
|
.find(|dev| {
|
||||||
|
if let Ok(dev) = dev
|
||||||
|
&& let Ok(name) = dev.device_name()
|
||||||
|
&& name.eq(id)
|
||||||
|
{
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.and_then(|b| b.ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayBackend {
|
||||||
|
pub fn set_brightness(&mut self, percent: u8) {
|
||||||
|
match self {
|
||||||
|
DisplayBackend::Sysfs(dev) => {
|
||||||
|
let _ = dev.set(percent as u32);
|
||||||
|
}
|
||||||
|
DisplayBackend::DdcCi(display) => {
|
||||||
|
let _ = display.handle.set_vcp_feature(0x10, percent as u16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_brightness(&mut self) -> Option<u8> {
|
||||||
|
match self {
|
||||||
|
DisplayBackend::Sysfs(dev) => dev.get().and_then(|val| Ok(val as u8)).ok(),
|
||||||
|
DisplayBackend::DdcCi(display) => {
|
||||||
|
if let Ok(val) = display.handle.get_vcp_feature(0x10) {
|
||||||
|
Some(val.value() as u8)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum BrightnessMessage {
|
||||||
|
Init(Sender<BrightnessMessage>),
|
||||||
|
Value(String, u8),
|
||||||
|
Request(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DisplayData(DisplayBackend, u8);
|
||||||
|
|
||||||
|
impl DisplayData {
|
||||||
|
fn update(&mut self) -> bool {
|
||||||
|
if let Some(value) = self.0.get_brightness() {
|
||||||
|
if self.1 != value {
|
||||||
|
self.1 = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_brightness(&mut self, percent: u8) {
|
||||||
|
self.0.set_brightness(percent);
|
||||||
|
self.1 = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BrightnessManager {
|
||||||
|
display_cache: HashMap<String, DisplayData>,
|
||||||
|
subscribers: Vec<Sender<BrightnessMessage>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendThread<BrightnessMessage, Sender<BrightnessMessage>> for BrightnessManager {
|
||||||
|
fn subscribe(
|
||||||
|
registrator: Registrator<BrightnessMessage>,
|
||||||
|
) -> (Receiver<BrightnessMessage>, Sender<BrightnessMessage>) {
|
||||||
|
let (b2f_tx, b2f_rx) = crossbeam_channel::unbounded::<BrightnessMessage>();
|
||||||
|
let _ = registrator.send(b2f_tx);
|
||||||
|
while let Ok(message) = b2f_rx.recv() {
|
||||||
|
return match message {
|
||||||
|
BrightnessMessage::Init(sender) => (b2f_rx, sender),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
panic!("Channel closed unexpectedly, without returning anything")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribers(&self) -> &Vec<Sender<BrightnessMessage>> {
|
||||||
|
&self.subscribers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(registration_rx: Receiver<Sender<BrightnessMessage>>) {
|
||||||
|
let (f2b_tx, f2b_rx) = crossbeam_channel::unbounded::<BrightnessMessage>();
|
||||||
|
let instance = Arc::new(Mutex::new(Self {
|
||||||
|
display_cache: HashMap::new(),
|
||||||
|
subscribers: Vec::new(),
|
||||||
|
}));
|
||||||
|
let instance_clone = instance.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
while let Ok(tx) = registration_rx.recv() {
|
||||||
|
if tx.send(BrightnessMessage::Init(f2b_tx.clone())).is_ok() {
|
||||||
|
trace!(&LOGGER, "Added Brightness Subscriber: {tx:?}");
|
||||||
|
instance_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock")
|
||||||
|
.subscribers
|
||||||
|
.push(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let instance_clone = instance.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
while let Ok(mut msg) = f2b_rx.recv() {
|
||||||
|
while let Ok(newer) = f2b_rx.try_recv() {
|
||||||
|
msg = newer;
|
||||||
|
}
|
||||||
|
match msg {
|
||||||
|
BrightnessMessage::Value(id, val) => {
|
||||||
|
instance_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock")
|
||||||
|
.set_brightness(id, val);
|
||||||
|
}
|
||||||
|
BrightnessMessage::Request(id) => {
|
||||||
|
instance_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock")
|
||||||
|
.register_display(id.clone());
|
||||||
|
if let Some(val) = instance_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock")
|
||||||
|
.display_cache
|
||||||
|
.get(&id)
|
||||||
|
.and_then(|v| Some(v.1))
|
||||||
|
{
|
||||||
|
instance_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock")
|
||||||
|
.publish(BrightnessMessage::Value(id, val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
thread::sleep(Duration::from_millis(10_000));
|
||||||
|
let mut messages = vec![];
|
||||||
|
|
||||||
|
for monitor in &mut instance.lock().expect("Couldn't get lock").display_cache {
|
||||||
|
if monitor.1.update() {
|
||||||
|
messages.push(BrightnessMessage::Value(monitor.0.clone(), monitor.1.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for message in messages {
|
||||||
|
instance.lock().expect("Couldn't get lock").publish(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrightnessManager {
|
||||||
|
pub fn set_brightness(&mut self, connector: String, percent: u8) {
|
||||||
|
if !self.display_cache.contains_key(&connector) {
|
||||||
|
self.register_display(connector.clone());
|
||||||
|
}
|
||||||
|
if let Some(display) = self.display_cache.get_mut(&connector) {
|
||||||
|
display.set_brightness(percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn _get_brightness(&mut self, connector: String) -> Option<u8> {
|
||||||
|
if !self.display_cache.contains_key(&connector) {
|
||||||
|
self.register_display(connector.clone());
|
||||||
|
}
|
||||||
|
if let Some(display) = self.display_cache.get_mut(&connector) {
|
||||||
|
display.0.get_brightness()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_display(&mut self, connector: String) {
|
||||||
|
let Ok(dir) = fs::read_dir("/sys/class/backlight/") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for path in dir {
|
||||||
|
let Ok(bl_path) = path else { continue };
|
||||||
|
let Ok(dir) = fs::read_dir(bl_path.path()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for path in dir {
|
||||||
|
let Ok(path) = path else { continue };
|
||||||
|
if path.file_name().eq("device")
|
||||||
|
&& let Ok(link) = fs::read_link(path.path())
|
||||||
|
&& let Some(name) = link.file_name()
|
||||||
|
{
|
||||||
|
if name
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|name| name.eq(&format!("card1-{connector}")))
|
||||||
|
{
|
||||||
|
println!("Found sysfs display {connector}");
|
||||||
|
let path = bl_path.file_name();
|
||||||
|
let Some(name) = path.to_str() else { continue };
|
||||||
|
let Some(device) = get_sysfs_dev(name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut backend = DisplayBackend::Sysfs(device);
|
||||||
|
let value = backend.get_brightness().unwrap_or(60);
|
||||||
|
self.display_cache
|
||||||
|
.insert(connector.clone(), DisplayData(backend, value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let res = Self::ddcutil_bus_for_connector(&connector);
|
||||||
|
if let Ok(path) = res
|
||||||
|
&& let Ok(data) = fs::metadata(path)
|
||||||
|
{
|
||||||
|
let id = data.rdev();
|
||||||
|
println!("Found DDC/CI display {connector}");
|
||||||
|
for display in ddc_hi::Display::enumerate() {
|
||||||
|
if id.to_string().eq(&display.info.id) {
|
||||||
|
let mut backend = DisplayBackend::DdcCi(display);
|
||||||
|
let value = backend.get_brightness().unwrap_or(60);
|
||||||
|
self.display_cache
|
||||||
|
.insert(connector.clone(), DisplayData(backend, value));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ddcutil_bus_for_connector(connector: &str) -> Result<PathBuf, Box<dyn Error>> {
|
||||||
|
let output = Command::new("ddcutil")
|
||||||
|
.args(["detect", "--verbose"])
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(std::io::Error::other("ddcutil detect --verbose failed").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the output into display blocks.
|
||||||
|
let mut blocks: Vec<Vec<&str>> = Vec::new();
|
||||||
|
|
||||||
|
for line in std::str::from_utf8(&output.stdout)?.lines() {
|
||||||
|
if line.starts_with("Display ") || line.starts_with("Invalid display") {
|
||||||
|
blocks.push(vec![line]);
|
||||||
|
} else if let Some(block) = blocks.last_mut() {
|
||||||
|
block.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for block in blocks {
|
||||||
|
// Skip displays that ddcutil identifies as invalid.
|
||||||
|
if block[0].starts_with("Invalid display") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bus: Option<PathBuf> = None;
|
||||||
|
let mut drm_connector: Option<String> = None;
|
||||||
|
|
||||||
|
for line in block.iter().skip(1) {
|
||||||
|
let Some((key, value)) = line.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accept both "DRM connector" and "DRM_connector".
|
||||||
|
let key = key.trim().replace('_', " ");
|
||||||
|
let value = value.trim();
|
||||||
|
|
||||||
|
if key.eq_ignore_ascii_case("I2C bus") {
|
||||||
|
bus = Some(PathBuf::from(value));
|
||||||
|
} else if key.eq_ignore_ascii_case("DRM connector") {
|
||||||
|
// Convert "card1-HDMI-A-1" to "HDMI-A-1".
|
||||||
|
drm_connector = Some(
|
||||||
|
value
|
||||||
|
.strip_prefix("card")
|
||||||
|
.and_then(|s| s.split_once('-'))
|
||||||
|
.map(|(_, name)| name)
|
||||||
|
.unwrap_or(value)
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if drm_connector.as_deref() == Some(connector) {
|
||||||
|
if let Some(bus) = bus {
|
||||||
|
return Ok(bus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
format!("No DDC-capable display found for {connector}"),
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
488
src/backend/network.rs
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
use crate::LOGGER;
|
||||||
|
use crate::backend::BackendThread;
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use nmrs::raw::zbus;
|
||||||
|
use nmrs::raw::zbus::Error;
|
||||||
|
use nmrs::raw::zbus::names::{ErrorName, InterfaceName};
|
||||||
|
use nmrs::raw::zvariant::OwnedObjectPath;
|
||||||
|
use nmrs::{ActiveConnection, ConnectionError, NetworkEvent, NetworkManager, WifiSecurity};
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
use tokio::time::{Instant, Sleep};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum NetworkType {
|
||||||
|
Wifi,
|
||||||
|
Wired,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NetworkInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub network_type: NetworkType,
|
||||||
|
pub signal: u8,
|
||||||
|
pub secured: Option<bool>,
|
||||||
|
pub known: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for NetworkInfo {}
|
||||||
|
|
||||||
|
impl PartialEq<Self> for NetworkInfo {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
other.name.eq(&self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd<Self> for NetworkInfo {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||||
|
Some(other.cmp(self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for NetworkInfo {
|
||||||
|
fn cmp(&self, other: &Self) -> Ordering {
|
||||||
|
self.signal
|
||||||
|
.cmp(&other.signal)
|
||||||
|
.then_with(|| self.name.cmp(&other.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum NetworkCommand {
|
||||||
|
ToggleVpn(String),
|
||||||
|
Connect(String, Auth),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum Auth {
|
||||||
|
Open,
|
||||||
|
Password(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum AuthType {
|
||||||
|
Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, thiserror::Error)]
|
||||||
|
pub enum ConnectError {
|
||||||
|
#[error("Authentication Failure")]
|
||||||
|
AuthFailed,
|
||||||
|
#[error("Network not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("Unknown error")]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ConnectionError> for ConnectError {
|
||||||
|
fn from(value: ConnectionError) -> Self {
|
||||||
|
match value {
|
||||||
|
ConnectionError::AuthFailed => ConnectError::AuthFailed,
|
||||||
|
ConnectionError::NotFound => ConnectError::NotFound,
|
||||||
|
ConnectionError::Dbus(err) => match err {
|
||||||
|
Error::MethodError(name, ..) => {
|
||||||
|
if name.eq(&ErrorName::try_from(
|
||||||
|
"org.freedesktop.NetworkManager.Device.SpecificObjectNotFound",
|
||||||
|
)
|
||||||
|
.unwrap())
|
||||||
|
{
|
||||||
|
ConnectError::NotFound
|
||||||
|
} else {
|
||||||
|
ConnectError::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => ConnectError::Unknown,
|
||||||
|
},
|
||||||
|
_ => ConnectError::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum NetworkState {
|
||||||
|
Init(UnboundedSender<NetworkCommand>),
|
||||||
|
ChangeNetwork(Option<NetworkInfo>),
|
||||||
|
VpnState { profile: String, connected: bool },
|
||||||
|
Networks(Vec<NetworkInfo>),
|
||||||
|
Auth(String, AuthType),
|
||||||
|
ConnectFail(String, ConnectError),
|
||||||
|
ConnectSuccess(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NetworkBackendHandle {
|
||||||
|
subscribers: Vec<Sender<NetworkState>>,
|
||||||
|
command_tx: UnboundedSender<NetworkCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EventDebouncer {
|
||||||
|
duration: Duration,
|
||||||
|
deadline: Option<Instant>,
|
||||||
|
sleep: Pin<Box<Sleep>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendThread<NetworkState, UnboundedSender<NetworkCommand>> for NetworkBackendHandle {
|
||||||
|
fn subscribe(
|
||||||
|
registrator: Sender<Sender<NetworkState>>,
|
||||||
|
) -> (Receiver<NetworkState>, UnboundedSender<NetworkCommand>) {
|
||||||
|
let (result_tx, result_rx) = crossbeam_channel::unbounded::<NetworkState>();
|
||||||
|
let _ = registrator.send(result_tx);
|
||||||
|
while let Ok(state) = result_rx.recv() {
|
||||||
|
return match state {
|
||||||
|
NetworkState::Init(sender) => (result_rx, sender),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
panic!("Channel closed unexpectedly, without returning anything")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribers(&self) -> &Vec<Sender<NetworkState>> {
|
||||||
|
&self.subscribers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(registration_rx: Receiver<Sender<NetworkState>>) {
|
||||||
|
let (command_tx, mut command_rx) = tokio::sync::mpsc::unbounded_channel::<NetworkCommand>();
|
||||||
|
let mut instance = Self {
|
||||||
|
subscribers: vec![],
|
||||||
|
command_tx,
|
||||||
|
};
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create Tokio runtime");
|
||||||
|
|
||||||
|
runtime.block_on(async move {
|
||||||
|
let nm = match NetworkManager::new().await {
|
||||||
|
Ok(nm) => nm,
|
||||||
|
Err(err) => {
|
||||||
|
error!(&LOGGER, "Error getting NetworkManager: {}", err.to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut events = match nm.network_events().await {
|
||||||
|
Ok(events) => events,
|
||||||
|
Err(err) => {
|
||||||
|
error!(&LOGGER, "Error getting NetworkManager events: {}", err.to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let mut debouncer = EventDebouncer {
|
||||||
|
duration: Duration::from_millis(200),
|
||||||
|
deadline: None,
|
||||||
|
sleep: Box::pin(tokio::time::sleep(Duration::from_secs(86400))),
|
||||||
|
};
|
||||||
|
debouncer.sleep.as_mut().reset(Instant::now() + Duration::from_secs(86400));
|
||||||
|
loop {
|
||||||
|
while let Ok(tx) = registration_rx.try_recv() {
|
||||||
|
if tx.send(NetworkState::Init(instance.command_tx.clone())).is_ok() {
|
||||||
|
instance.subscribers.push(tx);
|
||||||
|
let _ = instance.send_all_networks(&nm).await;
|
||||||
|
let _ = instance.send_active_connection(&nm).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
command = command_rx.recv() => {
|
||||||
|
let Some(command) = command else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run the command without stopping event processing
|
||||||
|
// while a potentially slow connection operation runs.
|
||||||
|
let nm = nm.clone();
|
||||||
|
let instance_clone = instance.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match command {
|
||||||
|
NetworkCommand::ToggleVpn(profile) => instance_clone.toggle_vpn(profile, &nm).await,
|
||||||
|
NetworkCommand::Connect(ssid, auth) => {
|
||||||
|
if let Ok(saved) = nm.list_saved_connections().await {
|
||||||
|
if saved.iter().find(|c| c.id.eq(&ssid)).is_some() {
|
||||||
|
instance_clone.connect(&nm, ssid, WifiSecurity::Open).await;
|
||||||
|
} else {
|
||||||
|
match auth {
|
||||||
|
Auth::Open => {
|
||||||
|
if let Ok(networks) = nm.list_access_points(None).await {
|
||||||
|
if let Some(ap) = networks.iter().find(|net| net.ssid.eq(&ssid)) {
|
||||||
|
if ap.security.is_open() {
|
||||||
|
instance_clone.connect(&nm, ssid, WifiSecurity::Open).await;
|
||||||
|
} else {
|
||||||
|
instance_clone.publish(NetworkState::Auth(ssid, AuthType::Password));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Auth::Password(password) => instance_clone.connect(&nm, ssid, WifiSecurity::WpaPsk { psk: password }).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = nm;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
event = events.next() => {
|
||||||
|
match event {
|
||||||
|
Some(event_res) => {
|
||||||
|
match event_res {
|
||||||
|
Ok(event) => instance.handle_event(&nm, event, &mut debouncer).await,
|
||||||
|
Err(err) => error!(&LOGGER, "Error: {err:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = &mut debouncer.sleep, if debouncer.deadline.is_some() => {
|
||||||
|
debouncer.deadline = None;
|
||||||
|
let _ = instance.send_all_networks(&nm).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkBackendHandle {
|
||||||
|
async fn connect(&self, nm: &NetworkManager, ssid: String, creds: WifiSecurity) {
|
||||||
|
let previous_ssid = nm.current_ssid().await;
|
||||||
|
let res = nm.connect(&ssid, None, creds).await;
|
||||||
|
if res.is_err() {
|
||||||
|
let err = res.err().unwrap();
|
||||||
|
warn!(&LOGGER, "Error connecting to network {ssid}: {:?}", err);
|
||||||
|
self.print_network_info(nm, &ssid, Level::WARN).await;
|
||||||
|
self.publish(NetworkState::ConnectFail(ssid, ConnectError::from(err)));
|
||||||
|
if let Some(prev) = previous_ssid {
|
||||||
|
let _ = nm.connect(&prev, None, WifiSecurity::Open).await;
|
||||||
|
}
|
||||||
|
self.send_all_networks(nm).await;
|
||||||
|
} else {
|
||||||
|
self.publish(NetworkState::ConnectSuccess(ssid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn print_network_info(&self, nm: &NetworkManager, ssid: &str, level: Level) {
|
||||||
|
if let Ok(nets) = nm.list_networks(None).await {
|
||||||
|
if let Some(net) = nets.iter().find(|net| net.ssid.eq(ssid)) {
|
||||||
|
log!(&LOGGER, level, "Network:");
|
||||||
|
log!(&LOGGER, level, " SSID: {}", net.ssid);
|
||||||
|
log!(&LOGGER, level, " Active: {}", net.is_active);
|
||||||
|
log!(&LOGGER, level, " Known: {}", net.known);
|
||||||
|
log!(&LOGGER, level, " Secured: {}", net.secured);
|
||||||
|
if net.secured {
|
||||||
|
log!(&LOGGER, level, " Features: {:?}", net.security_features);
|
||||||
|
log!(&LOGGER, level, " EAP: {}", net.is_eap);
|
||||||
|
log!(&LOGGER, level, " PSK: {}", net.is_psk);
|
||||||
|
}
|
||||||
|
log!(&LOGGER, level, " Strength: {:?}", net.strength);
|
||||||
|
log!(&LOGGER, level, " Device: {}", net.device);
|
||||||
|
log!(&LOGGER, level, " Addr(4): {:?}", net.ip4_address);
|
||||||
|
log!(&LOGGER, level, " Addr(6): {:?}", net.ip6_address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_event(
|
||||||
|
&self,
|
||||||
|
nm: &NetworkManager,
|
||||||
|
event: NetworkEvent,
|
||||||
|
debouncer: &mut EventDebouncer,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
NetworkEvent::AccessPointsChanged => {
|
||||||
|
let deadline = Instant::now() + debouncer.duration;
|
||||||
|
debouncer.deadline = Some(deadline);
|
||||||
|
debouncer.sleep.as_mut().reset(deadline);
|
||||||
|
}
|
||||||
|
NetworkEvent::WirelessEnabledChanged => {
|
||||||
|
if let Ok(state) = nm.wifi_state().await {
|
||||||
|
if !state.enabled {
|
||||||
|
let _ = self.publish(NetworkState::ChangeNetwork(None));
|
||||||
|
} else {
|
||||||
|
let _ = self.send_active_connection(nm).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NetworkEvent::ActiveConnectionsChanged => {
|
||||||
|
let _ = self.send_active_connection(nm).await;
|
||||||
|
}
|
||||||
|
NetworkEvent::DeviceChanged { interface } => match interface {
|
||||||
|
Some(interface) => match nm.active_vpn_connections().await {
|
||||||
|
Ok(connections) => {
|
||||||
|
let mut connected = false;
|
||||||
|
for conn in connections {
|
||||||
|
if conn.interface.is_some_and(|s| s.eq(&interface)) {
|
||||||
|
connected = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = self.publish(NetworkState::VpnState {
|
||||||
|
profile: interface,
|
||||||
|
connected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => error!(&LOGGER, "Error getting active vpns: {}", err.to_string()),
|
||||||
|
},
|
||||||
|
None => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn primary_connection(nm: &NetworkManager) -> Option<String> {
|
||||||
|
let manager = zbus::fdo::PropertiesProxy::new(
|
||||||
|
nm.dbus_connection(),
|
||||||
|
"org.freedesktop.NetworkManager",
|
||||||
|
"/org/freedesktop/NetworkManager",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let path = OwnedObjectPath::try_from(
|
||||||
|
manager
|
||||||
|
.get(
|
||||||
|
InterfaceName::from_static_str("org.freedesktop.NetworkManager").unwrap(),
|
||||||
|
"PrimaryConnection",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let props = zbus::fdo::PropertiesProxy::new(
|
||||||
|
nm.dbus_connection(),
|
||||||
|
"org.freedesktop.NetworkManager",
|
||||||
|
path.as_str(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let is_default = bool::try_from(
|
||||||
|
props
|
||||||
|
.get(
|
||||||
|
InterfaceName::from_static_str(
|
||||||
|
"org.freedesktop.NetworkManager.Connection.Active",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"Default",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if is_default {
|
||||||
|
let uuid: String = String::try_from(
|
||||||
|
props
|
||||||
|
.get(
|
||||||
|
InterfaceName::from_static_str(
|
||||||
|
"org.freedesktop.NetworkManager.Connection.Active",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"Uuid",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
return Some(uuid);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_all_networks(&self, nm: &NetworkManager) {
|
||||||
|
let _ = nm.scan_networks(None).await;
|
||||||
|
if let Ok(connections) = nm.list_access_points(None).await {
|
||||||
|
let mut networks: Vec<NetworkInfo> = Vec::new();
|
||||||
|
for conn in connections {
|
||||||
|
if conn.is_hidden() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ssid = conn.ssid;
|
||||||
|
let known = nm.has_saved_connection(&ssid).await.is_ok_and(|b| b);
|
||||||
|
let network = NetworkInfo {
|
||||||
|
name: ssid,
|
||||||
|
network_type: NetworkType::Wifi,
|
||||||
|
signal: conn.strength,
|
||||||
|
secured: Some(!conn.security.is_open()),
|
||||||
|
known,
|
||||||
|
};
|
||||||
|
networks.push(network);
|
||||||
|
}
|
||||||
|
networks.sort();
|
||||||
|
self.publish(NetworkState::Networks(networks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_active_connection(&self, nm: &NetworkManager) {
|
||||||
|
trace!(&LOGGER, "Sending Active Connection");
|
||||||
|
if let Ok(connections) = nm.list_active_connections().await {
|
||||||
|
let connection = if let Some(primary) = Self::primary_connection(nm).await {
|
||||||
|
connections.iter().find(|c| match c {
|
||||||
|
ActiveConnection::Wired(c) => c.uuid.eq(&primary),
|
||||||
|
ActiveConnection::Wifi(c) => c.uuid.eq(&primary),
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
connections.first()
|
||||||
|
};
|
||||||
|
match connection {
|
||||||
|
Some(conn) => {
|
||||||
|
match conn {
|
||||||
|
ActiveConnection::Wired(c) => {
|
||||||
|
let info = NetworkInfo {
|
||||||
|
name: c.id.clone(),
|
||||||
|
network_type: NetworkType::Wired,
|
||||||
|
signal: 0,
|
||||||
|
secured: None,
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
let _ = self.publish(NetworkState::ChangeNetwork(Some(info)));
|
||||||
|
}
|
||||||
|
ActiveConnection::Wifi(c) => {
|
||||||
|
let info = NetworkInfo {
|
||||||
|
name: c.ssid.clone(),
|
||||||
|
network_type: NetworkType::Wifi,
|
||||||
|
signal: c.strength.unwrap_or(0),
|
||||||
|
secured: None, // Not relevant here
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
let _ = self.publish(NetworkState::ChangeNetwork(Some(info)));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = self.publish(NetworkState::ChangeNetwork(None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn toggle_vpn(&self, profile: String, nm: &NetworkManager) {
|
||||||
|
let operation = async {
|
||||||
|
let active = nm.active_vpn_connections().await?;
|
||||||
|
if active.iter().any(|vpn| vpn.name == profile) {
|
||||||
|
nm.disconnect_vpn(&profile).await?;
|
||||||
|
} else {
|
||||||
|
nm.connect_vpn_by_id(&profile).await?;
|
||||||
|
};
|
||||||
|
|
||||||
|
let active = nm.active_vpn_connections().await?;
|
||||||
|
Ok::<bool, Box<dyn std::error::Error>>(active.iter().any(|vpn| vpn.name == profile))
|
||||||
|
};
|
||||||
|
|
||||||
|
match operation.await {
|
||||||
|
Ok(connected) => {
|
||||||
|
let _ = self.publish(NetworkState::VpnState { profile, connected });
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/backend/perf_stats.rs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
use crate::LOGGER;
|
||||||
|
use crate::backend::{BackendThread, Registrator};
|
||||||
|
use all_smi::AllSmi;
|
||||||
|
use battery::State;
|
||||||
|
use battery::units::Time;
|
||||||
|
use battery::units::power::watt;
|
||||||
|
use battery::units::ratio::percent;
|
||||||
|
use battery::units::time::millisecond;
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
use sysinfo::{Disks, System};
|
||||||
|
|
||||||
|
const POLL_INTERVAL: u64 = 10_000;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Stats {
|
||||||
|
pub cpu: u8,
|
||||||
|
pub ram: u8,
|
||||||
|
pub disks: Vec<(String, u8)>,
|
||||||
|
pub gpus: Vec<(String, u8)>,
|
||||||
|
pub battery_charge: u8,
|
||||||
|
pub battery_state: State,
|
||||||
|
pub battery_draw: f32,
|
||||||
|
pub battery_time: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StatsBackend {
|
||||||
|
subscribers: Vec<Sender<Stats>>,
|
||||||
|
system: System,
|
||||||
|
disks: Disks,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendThread<Stats, ()> for StatsBackend {
|
||||||
|
fn subscribe(registrator: Registrator<Stats>) -> (Receiver<Stats>, ()) {
|
||||||
|
let (tx, rx) = crossbeam_channel::unbounded::<Stats>();
|
||||||
|
let _ = registrator.send(tx);
|
||||||
|
(rx, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribers(&self) -> &Vec<Sender<Stats>> {
|
||||||
|
&self.subscribers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(registration_rx: Receiver<Sender<Stats>>) {
|
||||||
|
thread::spawn(move || {
|
||||||
|
let mut instance = Self {
|
||||||
|
subscribers: Vec::new(),
|
||||||
|
system: System::new(),
|
||||||
|
disks: Disks::new(),
|
||||||
|
};
|
||||||
|
let manager = battery::Manager::new();
|
||||||
|
let smi = AllSmi::new();
|
||||||
|
loop {
|
||||||
|
while let Ok(tx) = registration_rx.try_recv() {
|
||||||
|
trace!(&LOGGER, "Added Stats Subscriber: {tx:?}");
|
||||||
|
instance.subscribers.push(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.system.refresh_cpu_usage();
|
||||||
|
instance.system.refresh_memory();
|
||||||
|
instance.disks.refresh(true);
|
||||||
|
|
||||||
|
let cpu = instance.system.global_cpu_usage() as u8;
|
||||||
|
let ram =
|
||||||
|
(instance.system.used_memory() * 100 / instance.system.total_memory()) as u8;
|
||||||
|
|
||||||
|
let disk_stats: Vec<(String, u8)> = instance
|
||||||
|
.disks
|
||||||
|
.iter()
|
||||||
|
.map(|disk| {
|
||||||
|
let mount_point = disk.mount_point().display().to_string();
|
||||||
|
let used_space = disk.total_space() - disk.available_space();
|
||||||
|
let percentage = (used_space * 100 / disk.total_space()) as u8;
|
||||||
|
(mount_point, percentage)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut gpus: Vec<(String, u8)> = Vec::new();
|
||||||
|
if let Ok(smi) = &smi {
|
||||||
|
gpus = smi
|
||||||
|
.get_gpu_info()
|
||||||
|
.iter()
|
||||||
|
.map(|info| (info.name.clone(), info.utilization as u8))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut battery_charge: u8 = 255; // Use > 100 as invalid value
|
||||||
|
let mut battery_state = State::Unknown;
|
||||||
|
let mut battery_draw: f32 = 0.0;
|
||||||
|
let mut battery_time: u64 = 0;
|
||||||
|
|
||||||
|
if let Ok(manager) = &manager {
|
||||||
|
if let Ok(mut batteries) = manager.batteries() {
|
||||||
|
match batteries.next() {
|
||||||
|
Some(Ok(battery)) => {
|
||||||
|
battery_charge =
|
||||||
|
battery.state_of_charge().get::<percent>().round() as u8;
|
||||||
|
battery_state = battery.state();
|
||||||
|
battery_draw = battery.energy_rate().get::<watt>();
|
||||||
|
battery_time = if battery_state == State::Charging {
|
||||||
|
battery
|
||||||
|
.time_to_full()
|
||||||
|
.unwrap_or(Time::new::<millisecond>(0.0))
|
||||||
|
.get::<millisecond>()
|
||||||
|
as u64
|
||||||
|
} else if battery_state == State::Discharging {
|
||||||
|
battery
|
||||||
|
.time_to_empty()
|
||||||
|
.unwrap_or(Time::new::<millisecond>(0.0))
|
||||||
|
.get::<millisecond>()
|
||||||
|
as u64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Some(Err(e)) => error!(&LOGGER, "Error accessing battery: {e:?}"),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = Stats {
|
||||||
|
cpu,
|
||||||
|
ram,
|
||||||
|
disks: disk_stats,
|
||||||
|
gpus,
|
||||||
|
battery_charge,
|
||||||
|
battery_state,
|
||||||
|
battery_draw,
|
||||||
|
battery_time,
|
||||||
|
};
|
||||||
|
instance.publish(msg);
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(POLL_INTERVAL));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/backend/tailscale.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
use crate::LOGGER;
|
||||||
|
use crate::backend::{BackendThread, Registrator};
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
use http::Request;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use hyper::body::Incoming;
|
||||||
|
use hyper_util::rt::TokioIo;
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
pub struct TailscaleBackend {
|
||||||
|
subscribers: Vec<Sender<TailscaleState>>,
|
||||||
|
state: TailscaleState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TailscaleState {
|
||||||
|
NoState = 0,
|
||||||
|
InUseOtherUser = 1,
|
||||||
|
NeedsLogin = 2,
|
||||||
|
NeedsMachineAuth = 3,
|
||||||
|
Stopped = 4,
|
||||||
|
Starting = 5,
|
||||||
|
Running = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TailscaleState {
|
||||||
|
fn try_from_value(value: i64) -> Option<TailscaleState> {
|
||||||
|
match value {
|
||||||
|
0 => Some(TailscaleState::NoState),
|
||||||
|
1 => Some(TailscaleState::InUseOtherUser),
|
||||||
|
2 => Some(TailscaleState::NeedsLogin),
|
||||||
|
3 => Some(TailscaleState::NeedsMachineAuth),
|
||||||
|
4 => Some(TailscaleState::Stopped),
|
||||||
|
5 => Some(TailscaleState::Starting),
|
||||||
|
6 => Some(TailscaleState::Running),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TailscaleBackend {
|
||||||
|
async fn watch_tailscale(
|
||||||
|
instance: Arc<Mutex<Self>>,
|
||||||
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
let socket = UnixStream::connect("/var/run/tailscale/tailscaled.sock").await?;
|
||||||
|
let io = TokioIo::new(socket);
|
||||||
|
|
||||||
|
let (mut sender, connection) = hyper::client::conn::http1::handshake(io).await?;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = connection.await {
|
||||||
|
warn!(&LOGGER, "Tailscale socket HTTP connection error: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("http://local-tailscaled.sock/localapi/v0/watch-ipn-bus")
|
||||||
|
.header("Host", "local-tailscaled.sock")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.body(http_body_util::Empty::<hyper::body::Bytes>::new())?;
|
||||||
|
|
||||||
|
let response = sender.send_request(request).await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Tailscale API returned {}", response.status()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut body: Incoming = response.into_body();
|
||||||
|
let mut pending = Vec::<u8>::new();
|
||||||
|
|
||||||
|
while let Some(frame_result) = body.frame().await {
|
||||||
|
let frame = frame_result?;
|
||||||
|
|
||||||
|
if let Ok(data) = frame.into_data() {
|
||||||
|
pending.extend_from_slice(&data);
|
||||||
|
|
||||||
|
while let Some(newline) = pending.iter().position(|&b| b == b'\n') {
|
||||||
|
let line: Vec<u8> = pending.drain(..=newline).collect();
|
||||||
|
let line = &line[..line.len() - 1];
|
||||||
|
|
||||||
|
if line.iter().all(u8::is_ascii_whitespace) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match serde_json::from_slice::<serde_json::Value>(line) {
|
||||||
|
Ok(notification) => {
|
||||||
|
if let Some(state) = notification.get("State") {
|
||||||
|
if !state.is_null() {
|
||||||
|
let state =
|
||||||
|
TailscaleState::try_from_value(state.as_i64().unwrap_or(4))
|
||||||
|
.unwrap_or(TailscaleState::Stopped);
|
||||||
|
instance.lock().expect("Couldn't get lock").publish(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Invalid Tailscale notification: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendThread<TailscaleState, ()> for TailscaleBackend {
|
||||||
|
fn subscribe(registrator: Registrator<TailscaleState>) -> (Receiver<TailscaleState>, ()) {
|
||||||
|
let (tx, rx) = crossbeam_channel::unbounded::<TailscaleState>();
|
||||||
|
let _ = registrator.send(tx);
|
||||||
|
(rx, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribers(&self) -> &Vec<Sender<TailscaleState>> {
|
||||||
|
&self.subscribers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(registration_rx: Receiver<Sender<TailscaleState>>) {
|
||||||
|
let state = initial_state();
|
||||||
|
|
||||||
|
let instance = Arc::new(Mutex::new(Self {
|
||||||
|
subscribers: vec![],
|
||||||
|
state,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let instance_clone = instance.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
while let Ok(tx) = registration_rx.recv() {
|
||||||
|
trace!(&LOGGER, "Added Tailscale Subscriber: {tx:?}");
|
||||||
|
|
||||||
|
let _ = tx.send(
|
||||||
|
instance_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock")
|
||||||
|
.state
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
instance_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get lock")
|
||||||
|
.subscribers
|
||||||
|
.push(tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create Tokio runtime");
|
||||||
|
|
||||||
|
runtime.block_on(async move {
|
||||||
|
loop {
|
||||||
|
if let Err(e) = Self::watch_tailscale(instance.clone()).await {
|
||||||
|
warn!(&LOGGER, "Error: {e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_state() -> TailscaleState {
|
||||||
|
if let Ok(output) = Command::new("tailscale")
|
||||||
|
.arg("status")
|
||||||
|
.arg("--json")
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let val: Result<serde_json::Value, _> = serde_json::from_slice(&output.stdout);
|
||||||
|
if val.is_ok() {
|
||||||
|
if let Some(res) = val.unwrap().get("BackendState").and_then(|v| v.as_str()) {
|
||||||
|
if res.eq("Running") {
|
||||||
|
return TailscaleState::Running;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!(&LOGGER, "{:?}", val.err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TailscaleState::Stopped
|
||||||
|
}
|
||||||
207
src/backend/window_manager_hypr.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
use crate::LOGGER;
|
||||||
|
use crate::backend::BackendThread;
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
use hyprland::data::{Client, Monitor, Workspaces};
|
||||||
|
use hyprland::event_listener::EventListener;
|
||||||
|
use hyprland::prelude::*;
|
||||||
|
use hyprland::shared::{MonitorId, WorkspaceId, WorkspaceType};
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use std::io;
|
||||||
|
use std::process::{Command, Output};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum WindowManagerEvent {
|
||||||
|
WorkspaceChanged(i32),
|
||||||
|
WorkspaceAdded(i32, MonitorId),
|
||||||
|
WorkspaceRemoved(i32),
|
||||||
|
WindowFocusChanged(String),
|
||||||
|
SubmapChange(Option<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HyprlandComms {
|
||||||
|
subscribers: Vec<Sender<WindowManagerEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendThread<WindowManagerEvent, ()> for HyprlandComms {
|
||||||
|
fn subscribe(
|
||||||
|
registrator: Sender<Sender<WindowManagerEvent>>,
|
||||||
|
) -> (Receiver<WindowManagerEvent>, ()) {
|
||||||
|
let (tx, rx) = crossbeam_channel::unbounded::<WindowManagerEvent>();
|
||||||
|
let _ = registrator.send(tx);
|
||||||
|
(rx, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribers(&self) -> &Vec<Sender<WindowManagerEvent>> {
|
||||||
|
&self.subscribers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(registration_rx: Receiver<Sender<WindowManagerEvent>>) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let instance = Arc::new(Mutex::new(Self {
|
||||||
|
subscribers: vec![],
|
||||||
|
}));
|
||||||
|
let mut listener = EventListener::new();
|
||||||
|
|
||||||
|
let inst_clone = instance.clone();
|
||||||
|
listener.add_workspace_changed_handler(move |workspace| {
|
||||||
|
if let WorkspaceType::Regular(name) = workspace.name {
|
||||||
|
if let Ok(id) = name.parse::<i32>() {
|
||||||
|
inst_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get Subscribers")
|
||||||
|
.publish(WindowManagerEvent::WorkspaceChanged(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let inst_clone = instance.clone();
|
||||||
|
listener.add_workspace_added_handler(move |workspace| {
|
||||||
|
if let WorkspaceType::Regular(name) = workspace.name {
|
||||||
|
//unpacks the name of the workspace
|
||||||
|
if let Ok(wss) = Workspaces::get() {
|
||||||
|
//gets all workspaces
|
||||||
|
if let Some(ws) = wss
|
||||||
|
.to_vec()
|
||||||
|
.iter()
|
||||||
|
.find(|ws| ws.id().unwrap() == workspace.id)
|
||||||
|
{
|
||||||
|
//find the current workspace Object from its id
|
||||||
|
if let Ok(id) = name.parse::<i32>() {
|
||||||
|
// parse the id as an int
|
||||||
|
inst_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get Subscribers")
|
||||||
|
.publish(WindowManagerEvent::WorkspaceAdded(
|
||||||
|
id,
|
||||||
|
ws.monitor_id.unwrap(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let inst_clone = instance.clone();
|
||||||
|
listener.add_workspace_deleted_handler(move |workspace| {
|
||||||
|
if let WorkspaceType::Regular(name) = workspace.name {
|
||||||
|
if let Ok(id) = name.parse::<i32>() {
|
||||||
|
inst_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get Subscribers")
|
||||||
|
.publish(WindowManagerEvent::WorkspaceRemoved(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let inst_clone = instance.clone();
|
||||||
|
listener.add_active_monitor_changed_v2_handler(move |evt| {
|
||||||
|
inst_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get Subscribers")
|
||||||
|
.publish(WindowManagerEvent::WorkspaceChanged(evt.workspace_id));
|
||||||
|
});
|
||||||
|
|
||||||
|
let inst_clone = instance.clone();
|
||||||
|
listener.add_active_window_changed_handler(move |data_opt| {
|
||||||
|
let title = data_opt.map(|data| data.title).unwrap_or(String::new());
|
||||||
|
inst_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get Subscribers")
|
||||||
|
.publish(WindowManagerEvent::WindowFocusChanged(title));
|
||||||
|
});
|
||||||
|
|
||||||
|
let inst_clone = instance.clone();
|
||||||
|
listener.add_workspace_moved_handler(move |event| {
|
||||||
|
if let Some(monitor_id) = Self::monitor_id_from_workspace_id(event.id) {
|
||||||
|
inst_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get Subscribers")
|
||||||
|
.publish(WindowManagerEvent::WorkspaceRemoved(event.id));
|
||||||
|
inst_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get Subscribers")
|
||||||
|
.publish(WindowManagerEvent::WorkspaceAdded(event.id, monitor_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let inst_clone = instance.clone();
|
||||||
|
listener.add_sub_map_changed_handler(move |submap| {
|
||||||
|
let submap = if submap.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(submap.clone())
|
||||||
|
};
|
||||||
|
inst_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get Subscribers")
|
||||||
|
.publish(WindowManagerEvent::SubmapChange(submap))
|
||||||
|
});
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let inst_clone = instance.clone();
|
||||||
|
while let Ok(tx) = registration_rx.recv() {
|
||||||
|
trace!(&LOGGER, "Added WM Hyprland Subscriber: {tx:?}");
|
||||||
|
inst_clone
|
||||||
|
.lock()
|
||||||
|
.expect("Couldn't get Subscribers")
|
||||||
|
.subscribers
|
||||||
|
.push(tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(err) = listener.start_listener() {
|
||||||
|
error!(&LOGGER, "Hyprland event listener failed {:?}", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HyprlandComms {
|
||||||
|
fn monitor_id_from_workspace_id(id: WorkspaceId) -> Option<MonitorId> {
|
||||||
|
if let Ok(wss) = Workspaces::get() {
|
||||||
|
if let Some(ws) = wss.to_vec().iter().find(|ws| ws.id().unwrap() == id) {
|
||||||
|
return ws.monitor_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn _current_monitor() -> MonitorId {
|
||||||
|
if let Ok(monitor) = Monitor::get_active() {
|
||||||
|
return monitor.id;
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_added(id: WorkspaceId, monitor: Option<MonitorId>) -> bool {
|
||||||
|
if let Ok(wss) = Workspaces::get() {
|
||||||
|
wss.to_vec()
|
||||||
|
.iter()
|
||||||
|
.find(|ws| {
|
||||||
|
let b1 = ws.id().unwrap() == id;
|
||||||
|
let b2 =
|
||||||
|
monitor.is_some_and(|id| ws.monitor_id.is_some_and(|mon_id| id == mon_id));
|
||||||
|
b1 && b2
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_current_title() -> String {
|
||||||
|
if let Ok(Some(client)) = Client::get_active() {
|
||||||
|
client.title
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch(dispatcher: &str) -> io::Result<Output> {
|
||||||
|
Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(format!("hyprctl dispatch \"{dispatcher}\""))
|
||||||
|
.output()
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/css.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use glib::ControlFlow;
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use notify::EventKind::{Create, Modify, Remove};
|
||||||
|
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use crate::LOGGER;
|
||||||
|
|
||||||
|
pub fn parse_css() -> Option<String> {
|
||||||
|
match grass::from_path(
|
||||||
|
"style/style.scss",
|
||||||
|
&grass::Options::default(),
|
||||||
|
) {
|
||||||
|
Ok(css) => {
|
||||||
|
debug!(&LOGGER, "(Re-)Loading CSS");
|
||||||
|
Some(css)
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
error!(&LOGGER, "SCSS compilation failed: {err}");
|
||||||
|
None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn watch_css<F>(reload: F) -> RecommendedWatcher
|
||||||
|
where F: Fn(String) -> () + 'static {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
|
let mut watcher = notify::recommended_watcher(tx).unwrap();
|
||||||
|
|
||||||
|
watcher
|
||||||
|
.watch(
|
||||||
|
Path::new("style"),
|
||||||
|
RecursiveMode::Recursive,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
glib::timeout_add_local(Duration::from_millis(1000), move || {
|
||||||
|
let mut changed = false;
|
||||||
|
while let Ok(Ok(event)) = rx.try_recv() {
|
||||||
|
match event.kind {
|
||||||
|
Create(_) | Modify(_) | Remove(_) => changed = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed && let Some(css) = parse_css() {
|
||||||
|
reload(css);
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlFlow::Continue
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher
|
||||||
|
}
|
||||||
58
src/main.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use crate::backend::Backend;
|
||||||
|
mod backend;
|
||||||
|
mod css;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use glib::clone;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Application, CssProvider, gdk, gio};
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use notify::RecommendedWatcher;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
pub const LOGGER: Logger = Logger::create(Level::DEBUG);
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
info!(&LOGGER, "Starting jmb-bar");
|
||||||
|
|
||||||
|
gio::resources_register_include!("templates.gresource").expect("Failed to register resources");
|
||||||
|
|
||||||
|
let app = Application::builder()
|
||||||
|
.application_id("net.jmb19905.jmb-bar")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let watcher_slot: Rc<RefCell<Option<RecommendedWatcher>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
let _ = Command::new("pkill hypridle").output(); // Kill existing hypridle processes
|
||||||
|
|
||||||
|
let backend = Backend::start();
|
||||||
|
let _guard = backend.create_drop_guard(); // Make sure hypridle gets killed when the process exits
|
||||||
|
trace!(&LOGGER, "Initialized DropGuard");
|
||||||
|
|
||||||
|
app.connect_startup(move |_| {
|
||||||
|
let provider = CssProvider::new();
|
||||||
|
if let Some(css) = css::parse_css() {
|
||||||
|
provider.load_from_string(&css);
|
||||||
|
}
|
||||||
|
gtk::style_context_add_provider_for_display(
|
||||||
|
&gdk::Display::default().expect("Could not connect to display"),
|
||||||
|
&provider,
|
||||||
|
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
);
|
||||||
|
let watcher = css::watch_css(move |css| provider.load_from_string(&css));
|
||||||
|
*watcher_slot.borrow_mut() = Some(watcher);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.connect_activate(clone!(
|
||||||
|
#[strong]
|
||||||
|
backend,
|
||||||
|
move |app| {
|
||||||
|
trace!(&LOGGER, "Starting bars");
|
||||||
|
ui::create_bars(app, backend.clone());
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
app.run();
|
||||||
|
}
|
||||||
236
src/ui.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
use crate::LOGGER;
|
||||||
|
use crate::backend::window_manager_hypr::{HyprlandComms, WindowManagerEvent};
|
||||||
|
use crate::backend::{Backend, BackendChannels};
|
||||||
|
use crate::ui::action_centre::ActionCentre;
|
||||||
|
use crossbeam_channel::TryRecvError;
|
||||||
|
use glib::ControlFlow;
|
||||||
|
use glib::prelude::Cast;
|
||||||
|
use gtk::gdk::Display;
|
||||||
|
use gtk::prelude::{BoxExt, ButtonExt, DisplayExt, GtkWindowExt, ListModelExt, WidgetExt};
|
||||||
|
use gtk::{Align, Application, ApplicationWindow, Button, Orientation, gdk};
|
||||||
|
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use mpris::PlayerFinder;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
mod action_centre;
|
||||||
|
mod systray;
|
||||||
|
mod title_panel;
|
||||||
|
mod utilities_panel;
|
||||||
|
mod widgets;
|
||||||
|
mod workspaces_panel;
|
||||||
|
|
||||||
|
pub fn create_bars(app: &Application, backend: Backend) {
|
||||||
|
let display = Display::default().expect("Couldn't find GDK Display");
|
||||||
|
let app_clone = app.clone();
|
||||||
|
let bars: Mutex<HashMap<u32, ApplicationWindow>> = Mutex::new(HashMap::new());
|
||||||
|
let ac_channels = backend.subscribe();
|
||||||
|
let action_centre = ActionCentre::create_action_centre(app.clone(), ac_channels);
|
||||||
|
for monitor in 0..display.monitors().n_items() {
|
||||||
|
debug!(&LOGGER, "Opening bar {monitor}");
|
||||||
|
let channels = backend.subscribe();
|
||||||
|
bars.lock().expect("Couldn't get active bars").insert(
|
||||||
|
monitor,
|
||||||
|
create_bar(
|
||||||
|
app_clone.clone(),
|
||||||
|
&display,
|
||||||
|
monitor,
|
||||||
|
action_centre.clone(),
|
||||||
|
channels,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
display
|
||||||
|
.monitors()
|
||||||
|
.connect_items_changed(move |_list, pos, removed, added| {
|
||||||
|
if removed == 1 {
|
||||||
|
debug!(&LOGGER, "Detected Monitor disconnect {pos}");
|
||||||
|
if let Some(bar) = bars.lock().expect("Couldn't get active bars").remove(&pos) {
|
||||||
|
bar.close();
|
||||||
|
}
|
||||||
|
} else if added == 1 {
|
||||||
|
debug!(&LOGGER, "Detected new monitor: {pos}");
|
||||||
|
let channels = backend.subscribe();
|
||||||
|
bars.lock().expect("Couldn't get active bars").insert(
|
||||||
|
pos,
|
||||||
|
create_bar(
|
||||||
|
app_clone.clone(),
|
||||||
|
&display,
|
||||||
|
pos,
|
||||||
|
action_centre.clone(),
|
||||||
|
channels,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_bar(
|
||||||
|
app: Application,
|
||||||
|
display: &Display,
|
||||||
|
monitor_idx: u32,
|
||||||
|
action_centre: ActionCentre,
|
||||||
|
mut channels: BackendChannels,
|
||||||
|
) -> ApplicationWindow {
|
||||||
|
// Take the Receiver and replace it with none so that it can be only used in one place
|
||||||
|
let hypr_rx = channels
|
||||||
|
.take_hypr_rx()
|
||||||
|
.expect("Tried taking unavailable Hyprland Channel Receiver");
|
||||||
|
|
||||||
|
let player_opt = if let Ok(player_finder) = PlayerFinder::new() {
|
||||||
|
if let Ok(player) = player_finder.find_active() {
|
||||||
|
Some(player)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let window = ApplicationWindow::builder()
|
||||||
|
.application(&app)
|
||||||
|
.title("JmbBar")
|
||||||
|
.default_width(500)
|
||||||
|
.default_height(30)
|
||||||
|
.css_classes(vec!["bar"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let monitors = display.monitors();
|
||||||
|
|
||||||
|
window.init_layer_shell();
|
||||||
|
window.set_layer(Layer::Top);
|
||||||
|
window.set_anchor(Edge::Top, true);
|
||||||
|
window.set_anchor(Edge::Left, true);
|
||||||
|
window.set_anchor(Edge::Right, true);
|
||||||
|
window.set_margin(Edge::Top, 20);
|
||||||
|
if let Some(monitor) = monitors.item(monitor_idx) {
|
||||||
|
if let Ok(monitor) = monitor.downcast::<gdk::Monitor>() {
|
||||||
|
window.set_monitor(Some(&monitor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.set_exclusive_zone(30);
|
||||||
|
|
||||||
|
let mut buttons: HashMap<i32, Button> = HashMap::new();
|
||||||
|
let (workspaces, submap) = workspaces_panel::build(
|
||||||
|
&mut buttons,
|
||||||
|
window
|
||||||
|
.monitor()
|
||||||
|
.map(|monitor| monitor_index(&monitor))
|
||||||
|
.flatten(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let title_button: Option<Button>;
|
||||||
|
let title = title_panel::build();
|
||||||
|
|
||||||
|
let utilities = utilities_panel::build(&app, action_centre, channels, monitor_idx, display);
|
||||||
|
|
||||||
|
let dock = gtk::Box::new(Orientation::Horizontal, 10);
|
||||||
|
dock.add_css_class("dock");
|
||||||
|
dock.set_vexpand(true);
|
||||||
|
dock.set_valign(Align::Fill);
|
||||||
|
dock.append(&workspaces);
|
||||||
|
dock.append(&title);
|
||||||
|
dock.append(&utilities);
|
||||||
|
|
||||||
|
title_button = Some(title);
|
||||||
|
title_panel::manage_title(&title_button, &player_opt);
|
||||||
|
|
||||||
|
window.set_keyboard_mode(KeyboardMode::None);
|
||||||
|
window.set_child(Some(&dock));
|
||||||
|
window.present();
|
||||||
|
|
||||||
|
let window_clone = window.clone();
|
||||||
|
|
||||||
|
glib::timeout_add_local(Duration::from_millis(50), move || {
|
||||||
|
loop {
|
||||||
|
match hypr_rx.try_recv() {
|
||||||
|
Ok(event) => {
|
||||||
|
handle_wm_event(
|
||||||
|
&mut buttons,
|
||||||
|
&submap,
|
||||||
|
&title_button,
|
||||||
|
event,
|
||||||
|
window_clone.monitor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => break,
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
return ControlFlow::Break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title_panel::manage_title(&title_button, &player_opt);
|
||||||
|
|
||||||
|
ControlFlow::Continue
|
||||||
|
});
|
||||||
|
window
|
||||||
|
}
|
||||||
|
|
||||||
|
fn monitor_index(target: &gdk::Monitor) -> Option<u32> {
|
||||||
|
let display = Display::default()?;
|
||||||
|
let monitors = display.monitors();
|
||||||
|
|
||||||
|
(0..monitors.n_items()).find(|&i| {
|
||||||
|
monitors
|
||||||
|
.item(i)
|
||||||
|
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
|
||||||
|
.is_some_and(|monitor| monitor == *target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_wm_event(
|
||||||
|
buttons: &mut HashMap<i32, Button>,
|
||||||
|
submap_label: &Button,
|
||||||
|
title_button: &Option<Button>,
|
||||||
|
event: WindowManagerEvent,
|
||||||
|
current_monitor: Option<gdk::Monitor>,
|
||||||
|
) {
|
||||||
|
trace!(&LOGGER, "Received WM Event {event:?}");
|
||||||
|
match event {
|
||||||
|
WindowManagerEvent::WorkspaceChanged(workspace) => {
|
||||||
|
for (&id, button) in buttons {
|
||||||
|
if id == workspace {
|
||||||
|
button.add_css_class("active");
|
||||||
|
} else {
|
||||||
|
button.remove_css_class("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title_panel::set_title(&HyprlandComms::get_current_title(), title_button);
|
||||||
|
}
|
||||||
|
WindowManagerEvent::WorkspaceAdded(workspace, monitor) => {
|
||||||
|
for (&id, button) in buttons {
|
||||||
|
if id == workspace
|
||||||
|
&& current_monitor.as_ref().is_none_or(|cur_mon| {
|
||||||
|
monitor_index(&cur_mon).is_some_and(|idx| idx == monitor as u32)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
button.set_visible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowManagerEvent::WorkspaceRemoved(workspace) => {
|
||||||
|
for (&id, button) in buttons {
|
||||||
|
if id == workspace {
|
||||||
|
button.set_visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowManagerEvent::WindowFocusChanged(title) => {
|
||||||
|
title_panel::set_title(&title, title_button);
|
||||||
|
}
|
||||||
|
WindowManagerEvent::SubmapChange(submap) => match submap {
|
||||||
|
Some(submap) => {
|
||||||
|
submap_label.set_label(&submap);
|
||||||
|
submap_label.add_css_class("active");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
submap_label.set_label("");
|
||||||
|
submap_label.remove_css_class("active");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
383
src/ui/action_centre.rs
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
use crate::backend::BackendChannels;
|
||||||
|
use crate::backend::network::{
|
||||||
|
Auth, AuthType, ConnectError, NetworkCommand, NetworkInfo, NetworkState,
|
||||||
|
};
|
||||||
|
use crate::ui::widgets::{LabelEntry, WifiWidget};
|
||||||
|
use glib::prelude::Cast;
|
||||||
|
use glib::{ControlFlow, clone};
|
||||||
|
use gtk::gdk::Display;
|
||||||
|
use gtk::prelude::{
|
||||||
|
BoxExt, ButtonExt, DisplayExt, EditableExt, GtkWindowExt, ListModelExt, WidgetExt,
|
||||||
|
};
|
||||||
|
use gtk::{
|
||||||
|
Application, ApplicationWindow, Button, Image, Label, Orientation, PolicyType, ScrolledWindow,
|
||||||
|
Stack, gdk,
|
||||||
|
};
|
||||||
|
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||||
|
use std::cell::{Cell, RefCell, RefMut};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const WIFI_ICONS: [&str; 4] = [
|
||||||
|
"xsi-network-wireless-signal-none-symbolic",
|
||||||
|
"xsi-network-wireless-signal-weak-symbolic",
|
||||||
|
"xsi-network-wireless-signal-ok-symbolic",
|
||||||
|
"xsi-network-wireless-signal-good-symbolic",
|
||||||
|
];
|
||||||
|
const _WIFI_ICONS_SECURED: [&str; 4] = [
|
||||||
|
"xsi-network-wireless-signal-none-secure-symbolic",
|
||||||
|
"xsi-network-wireless-signal-weak-secure-symbolic",
|
||||||
|
"xsi-network-wireless-signal-ok-secure-symbolic",
|
||||||
|
"xsi-network-wireless-signal-good-secure-symbolic",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ANIMATION_DURATION: u64 = 200;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ActionCentre {
|
||||||
|
pub window: ApplicationWindow,
|
||||||
|
is_animating: Rc<Cell<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionCentre {
|
||||||
|
pub fn create_action_centre(app: Application, mut channels: BackendChannels) -> Self {
|
||||||
|
let window = ApplicationWindow::builder()
|
||||||
|
.application(&app)
|
||||||
|
.title("JmbBar")
|
||||||
|
.default_width(400)
|
||||||
|
.default_height(1080)
|
||||||
|
.css_classes(vec!["action-centre"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
window.init_layer_shell();
|
||||||
|
window.set_layer(Layer::Overlay);
|
||||||
|
window.set_anchor(Edge::Top, true);
|
||||||
|
window.set_anchor(Edge::Bottom, true);
|
||||||
|
window.set_anchor(Edge::Right, true);
|
||||||
|
window.set_margin(Edge::Top, 20);
|
||||||
|
window.set_margin(Edge::Right, 20);
|
||||||
|
window.set_margin(Edge::Bottom, 20);
|
||||||
|
window.set_keyboard_mode(KeyboardMode::OnDemand);
|
||||||
|
|
||||||
|
let instance = Self {
|
||||||
|
window: window.clone(),
|
||||||
|
is_animating: Rc::new(Cell::new(false)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let scrolled = ScrolledWindow::new();
|
||||||
|
scrolled.set_policy(PolicyType::Never, PolicyType::Automatic);
|
||||||
|
scrolled.set_vexpand(true);
|
||||||
|
let content = gtk::Box::new(Orientation::Vertical, 6);
|
||||||
|
|
||||||
|
let add_net_stack = Stack::new();
|
||||||
|
add_net_stack.set_vhomogeneous(false);
|
||||||
|
|
||||||
|
let add_network_button = Button::new();
|
||||||
|
add_network_button.add_css_class("wifi-button");
|
||||||
|
|
||||||
|
let add_net_box = gtk::Box::new(Orientation::Horizontal, 4);
|
||||||
|
let add_icon = Image::from_file("icons/plus-symbolic.svg");
|
||||||
|
add_icon.add_css_class("list-add-icon");
|
||||||
|
add_icon.add_css_class("icon");
|
||||||
|
add_icon.set_pixel_size(24);
|
||||||
|
add_net_box.append(&add_icon);
|
||||||
|
add_net_stack.add_named(&add_network_button, Some("button"));
|
||||||
|
|
||||||
|
let add_net_input_box = gtk::Box::new(Orientation::Vertical, 4);
|
||||||
|
let ssid = LabelEntry::new("SSID:");
|
||||||
|
add_net_input_box.append(&ssid);
|
||||||
|
let pw = LabelEntry::new("Passphrase:");
|
||||||
|
add_net_input_box.append(&pw);
|
||||||
|
add_net_stack.add_named(&add_net_input_box, Some("input"));
|
||||||
|
|
||||||
|
add_net_stack.set_visible_child_name("button");
|
||||||
|
|
||||||
|
add_network_button.connect_clicked(clone!(
|
||||||
|
#[strong]
|
||||||
|
add_net_stack,
|
||||||
|
move |_button| {
|
||||||
|
add_net_stack.set_visible_child_name("input");
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
let label = Label::new(Some("Add Network..."));
|
||||||
|
add_net_box.append(&label);
|
||||||
|
add_network_button.set_child(Some(&add_net_box));
|
||||||
|
|
||||||
|
content.append(&add_net_stack);
|
||||||
|
|
||||||
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
window.set_child(Some(&scrolled));
|
||||||
|
|
||||||
|
let instance_clone = instance.clone();
|
||||||
|
let key_controller = gtk::EventControllerKey::new();
|
||||||
|
key_controller.connect_key_pressed(move |_, key, _, _| {
|
||||||
|
if key == gdk::Key::Escape {
|
||||||
|
instance_clone.hide_action_centre();
|
||||||
|
glib::Propagation::Stop
|
||||||
|
} else {
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.add_controller(key_controller);
|
||||||
|
|
||||||
|
let wifi_networks: Rc<RefCell<HashMap<String, WifiWidget>>> =
|
||||||
|
Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
let wifi_networks_clone = wifi_networks.clone();
|
||||||
|
window.connect_focus_widget_notify(move |window| {
|
||||||
|
Self::focus_callback(&wifi_networks_clone, window)
|
||||||
|
});
|
||||||
|
|
||||||
|
let net_rx = channels.take_net_rx().unwrap();
|
||||||
|
let window_clone = window.clone();
|
||||||
|
let wifi_networks_clone = wifi_networks.clone();
|
||||||
|
glib::timeout_add_local(Duration::from_millis(100), move || {
|
||||||
|
while let Ok(message) = net_rx.try_recv() {
|
||||||
|
match message {
|
||||||
|
NetworkState::ChangeNetwork(info) => match info {
|
||||||
|
Some(info) => {
|
||||||
|
for (name, widget) in wifi_networks_clone.borrow().iter() {
|
||||||
|
if name.eq(&info.name) {
|
||||||
|
widget.add_css_class("connected");
|
||||||
|
} else {
|
||||||
|
widget.remove_css_class("connected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
},
|
||||||
|
NetworkState::Networks(networks) => {
|
||||||
|
for network in &networks {
|
||||||
|
let borrowed_wifi_networks_clone = wifi_networks_clone.borrow_mut();
|
||||||
|
let widget = borrowed_wifi_networks_clone.get(&network.name);
|
||||||
|
if widget.is_none() {
|
||||||
|
let widget = Self::create_wifi_widget(
|
||||||
|
&channels,
|
||||||
|
network,
|
||||||
|
borrowed_wifi_networks_clone,
|
||||||
|
);
|
||||||
|
content.append(&widget);
|
||||||
|
} else {
|
||||||
|
let idx = ((network.signal as f32 / 100.0)
|
||||||
|
* (WIFI_ICONS.len() - 1) as f32
|
||||||
|
+ 0.5) as usize;
|
||||||
|
widget.unwrap().set_wifi_icon_from_name(WIFI_ICONS[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut removal: Vec<String> = Vec::new();
|
||||||
|
for (ssid, widget) in wifi_networks_clone.borrow().iter() {
|
||||||
|
let network = &networks.iter().find(|n| n.name.eq(ssid));
|
||||||
|
if network.is_none() {
|
||||||
|
content.remove(widget);
|
||||||
|
removal.push(ssid.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ssid in removal {
|
||||||
|
wifi_networks_clone.borrow_mut().remove(&ssid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NetworkState::Auth(ssid, auth) => {
|
||||||
|
if let Some(widget) = Self::find_focused_widget((&window_clone).as_ref()) {
|
||||||
|
match auth {
|
||||||
|
AuthType::Password => {
|
||||||
|
if widget.ssid().eq(&ssid) {
|
||||||
|
widget.show_password_entry();
|
||||||
|
widget.stop_spinner();
|
||||||
|
widget.grab_focus_password_entry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NetworkState::ConnectFail(ssid, err) => {
|
||||||
|
let mut networks = wifi_networks_clone.borrow_mut();
|
||||||
|
if let Some(widget) = networks.get(&ssid) {
|
||||||
|
widget.stop_spinner();
|
||||||
|
match err {
|
||||||
|
ConnectError::AuthFailed => widget.show_password_entry(),
|
||||||
|
ConnectError::NotFound => {
|
||||||
|
content.remove(widget);
|
||||||
|
networks.remove(&ssid);
|
||||||
|
}
|
||||||
|
ConnectError::Unknown => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NetworkState::ConnectSuccess(ssid) => {
|
||||||
|
if let Some(widget) = wifi_networks_clone.borrow().get(&ssid) {
|
||||||
|
widget.stop_spinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ControlFlow::Continue
|
||||||
|
});
|
||||||
|
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_wifi_widget(
|
||||||
|
channels: &BackendChannels,
|
||||||
|
network: &NetworkInfo,
|
||||||
|
mut borrowed_wifi_networks_clone: RefMut<HashMap<String, WifiWidget>>,
|
||||||
|
) -> WifiWidget {
|
||||||
|
let ssid = network.name.clone();
|
||||||
|
let widget = WifiWidget::new();
|
||||||
|
widget.set_ssid(&ssid);
|
||||||
|
widget.add_css_class("wifi-button");
|
||||||
|
widget.set_secured(network.secured.unwrap());
|
||||||
|
widget.set_known(network.known);
|
||||||
|
let idx = ((network.signal as f32 / 100.0) * (WIFI_ICONS.len() - 1) as f32 + 0.5) as usize;
|
||||||
|
widget.set_wifi_icon_from_name(WIFI_ICONS[idx]);
|
||||||
|
borrowed_wifi_networks_clone.insert(ssid.clone(), widget.clone());
|
||||||
|
let net_tx = channels.net_tx();
|
||||||
|
widget.connect_clicked(clone!(
|
||||||
|
#[strong]
|
||||||
|
net_tx,
|
||||||
|
#[strong]
|
||||||
|
ssid,
|
||||||
|
move |widget| {
|
||||||
|
let _ = net_tx.send(NetworkCommand::Connect(ssid.clone(), Auth::Open));
|
||||||
|
widget.start_spinner();
|
||||||
|
}
|
||||||
|
));
|
||||||
|
widget.connect_activate_pass(clone!(
|
||||||
|
#[strong]
|
||||||
|
net_tx,
|
||||||
|
#[weak]
|
||||||
|
widget,
|
||||||
|
move |entry| {
|
||||||
|
let text = entry.text().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
let _ = net_tx.send(NetworkCommand::Connect(
|
||||||
|
ssid.clone(),
|
||||||
|
Auth::Password(entry.text().to_string()),
|
||||||
|
));
|
||||||
|
widget.start_spinner();
|
||||||
|
widget.show_ssid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
widget
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus_callback(
|
||||||
|
wifi_networks: &Rc<RefCell<HashMap<String, WifiWidget>>>,
|
||||||
|
window: &ApplicationWindow,
|
||||||
|
) {
|
||||||
|
for (_, widget) in wifi_networks.clone().borrow().iter() {
|
||||||
|
let focused_inside = window.focus().is_some_and(|mut focus| {
|
||||||
|
loop {
|
||||||
|
if focus == *widget {
|
||||||
|
break true;
|
||||||
|
}
|
||||||
|
|
||||||
|
match focus.parent() {
|
||||||
|
Some(parent) => focus = parent,
|
||||||
|
None => break false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !focused_inside {
|
||||||
|
widget.show_ssid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _has_focus_within(window: &ApplicationWindow) -> bool {
|
||||||
|
let Some(mut widget) = window.focus() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if widget == *window {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
match widget.parent() {
|
||||||
|
Some(parent) => widget = parent,
|
||||||
|
None => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_focused_widget(window: >k::Window) -> Option<WifiWidget> {
|
||||||
|
let mut widget = window.focus()?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Ok(widget) = widget.clone().downcast::<WifiWidget>() {
|
||||||
|
return Some(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
widget = widget.parent()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_action_centre(&self, monitor_idx: u32, display: &Display) {
|
||||||
|
let window = &self.window;
|
||||||
|
let monitors = display.monitors();
|
||||||
|
let Some(monitor) = monitors.item(monitor_idx) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(monitor) = monitor.downcast::<gdk::Monitor>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
window.set_monitor(Some(&monitor));
|
||||||
|
if !window.is_visible() {
|
||||||
|
self.animate_margin(-window.width() - 20, 20, ANIMATION_DURATION, || {});
|
||||||
|
window.present();
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hide_action_centre(&self) {
|
||||||
|
let clone = self.clone();
|
||||||
|
self.animate_margin(
|
||||||
|
20,
|
||||||
|
-clone.window.width() - 20,
|
||||||
|
ANIMATION_DURATION,
|
||||||
|
move || clone.window.set_visible(false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn animate_margin(
|
||||||
|
&self,
|
||||||
|
from: i32,
|
||||||
|
to: i32,
|
||||||
|
duration_ms: u64,
|
||||||
|
on_finished: impl Fn() + 'static,
|
||||||
|
) {
|
||||||
|
let window = self.window.clone();
|
||||||
|
let flag = self.is_animating.clone();
|
||||||
|
let start = Instant::now();
|
||||||
|
let duration = Duration::from_millis(duration_ms);
|
||||||
|
|
||||||
|
if flag.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::timeout_add_local(Duration::from_millis(10), move || {
|
||||||
|
flag.set(true);
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
let t = (elapsed.as_secs_f64() / duration.as_secs_f64()).clamp(0.0, 1.0);
|
||||||
|
// Ease-out cubic.
|
||||||
|
let eased = 1.0 - (1.0 - t).powi(3);
|
||||||
|
|
||||||
|
let margin = from as f64 + (to - from) as f64 * eased;
|
||||||
|
|
||||||
|
window.set_margin(Edge::Right, margin.round() as i32);
|
||||||
|
|
||||||
|
if t >= 1.0 {
|
||||||
|
on_finished();
|
||||||
|
flag.set(false);
|
||||||
|
ControlFlow::Break
|
||||||
|
} else {
|
||||||
|
ControlFlow::Continue
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/ui/systray.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use crate::LOGGER;
|
||||||
|
use glib::ControlFlow;
|
||||||
|
use gtk::Orientation;
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use rustsni::{TrayEvent, TrayHost};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub fn _build() -> gtk::Box {
|
||||||
|
let systray = gtk::Box::new(Orientation::Horizontal, 0);
|
||||||
|
let host = TrayHost::new();
|
||||||
|
|
||||||
|
if let Ok(mut host) = host {
|
||||||
|
glib::timeout_add_local(
|
||||||
|
Duration::from_millis(1_000),
|
||||||
|
move || {
|
||||||
|
for event in host.poll().unwrap() {
|
||||||
|
match event {
|
||||||
|
TrayEvent::ItemAdded(id) | TrayEvent::ItemChanged(id) => {
|
||||||
|
if let Some(item) = host.items().get(&id) {
|
||||||
|
println!("tray: {} — {}", item.title, item.status);
|
||||||
|
println!(" category: {}", item.category);
|
||||||
|
println!(" has menu: {}", item.has_menu());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrayEvent::ItemRemoved(id) => {
|
||||||
|
println!("item gone: {id}");
|
||||||
|
}
|
||||||
|
TrayEvent::HostShutdown => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ControlFlow::Continue
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
error!(&LOGGER, "Couldn't get SystemTray");
|
||||||
|
}
|
||||||
|
|
||||||
|
systray
|
||||||
|
}
|
||||||
95
src/ui/title_panel.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use crate::backend::window_manager_hypr::HyprlandComms;
|
||||||
|
use glib::prelude::CastNone;
|
||||||
|
use gtk::pango::EllipsizeMode;
|
||||||
|
use gtk::prelude::{ButtonExt, GestureSingleExt, WidgetExt};
|
||||||
|
use gtk::{Align, Button, GestureClick, Label};
|
||||||
|
use mpris::{PlaybackStatus, Player, PlayerFinder};
|
||||||
|
|
||||||
|
pub fn build() -> Button {
|
||||||
|
let title_label = Label::new(None);
|
||||||
|
title_label.set_ellipsize(EllipsizeMode::End);
|
||||||
|
title_label.set_max_width_chars(50);
|
||||||
|
|
||||||
|
let title = Button::new();
|
||||||
|
title.set_vexpand(true);
|
||||||
|
title.set_valign(Align::Fill);
|
||||||
|
title.set_child(Some(&title_label));
|
||||||
|
title.set_visible(false);
|
||||||
|
title.add_css_class("title");
|
||||||
|
title.connect_clicked(|button| {
|
||||||
|
if button.has_css_class("media") {
|
||||||
|
if let Ok(player_finder) = PlayerFinder::new() {
|
||||||
|
if let Ok(player) = player_finder.find_active() {
|
||||||
|
let _ = player.play_pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let right_click = GestureClick::new();
|
||||||
|
right_click.set_button(3);
|
||||||
|
right_click.connect_pressed(|_gesture, _n_press, _, _| {
|
||||||
|
if let Ok(player_finder) = PlayerFinder::new() {
|
||||||
|
if let Ok(player) = player_finder.find_active() {
|
||||||
|
let _ = player.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
title.add_controller(right_click);
|
||||||
|
title
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn manage_title(button_opt: &Option<Button>, player_opt: &Option<Player>) {
|
||||||
|
let mut handled = false;
|
||||||
|
if let Some(button) = button_opt {
|
||||||
|
if let Some(player) = player_opt {
|
||||||
|
if let Ok(status) = player.get_playback_status() {
|
||||||
|
if let Ok(metadata) = player.get_metadata() {
|
||||||
|
match status {
|
||||||
|
PlaybackStatus::Playing => {
|
||||||
|
button.set_visible(true);
|
||||||
|
button.child()
|
||||||
|
.and_downcast::<Label>()
|
||||||
|
.unwrap()
|
||||||
|
.set_label(&format!(" {}", metadata.title().unwrap_or("")));
|
||||||
|
button.add_css_class("media");
|
||||||
|
button.add_css_class("playing");
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
PlaybackStatus::Paused => {
|
||||||
|
button.set_visible(true);
|
||||||
|
button.child()
|
||||||
|
.and_downcast::<Label>()
|
||||||
|
.unwrap()
|
||||||
|
.set_label(&format!(" {}", metadata.title().unwrap_or("")));
|
||||||
|
button.add_css_class("media");
|
||||||
|
button.remove_css_class("playing");
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
PlaybackStatus::Stopped => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
button.set_visible(true);
|
||||||
|
button.remove_css_class("media");
|
||||||
|
button.remove_css_class("playing");
|
||||||
|
set_title(&HyprlandComms::get_current_title(), button_opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_title(title: &str, title_button: &Option<Button>) {
|
||||||
|
if let Some(button) = title_button {
|
||||||
|
if title.is_empty() {
|
||||||
|
button.set_visible(false);
|
||||||
|
} else {
|
||||||
|
button.set_visible(true);
|
||||||
|
}
|
||||||
|
button.child()
|
||||||
|
.and_downcast::<Label>()
|
||||||
|
.unwrap()
|
||||||
|
.set_label(&HyprlandComms::get_current_title());
|
||||||
|
}
|
||||||
|
}
|
||||||
521
src/ui/utilities_panel.rs
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
use crate::LOGGER;
|
||||||
|
use crate::backend::BackendChannels;
|
||||||
|
use crate::backend::audio::MediaCommand;
|
||||||
|
use crate::backend::brightness_control::BrightnessMessage;
|
||||||
|
use crate::backend::network::{NetworkCommand, NetworkState, NetworkType};
|
||||||
|
use crate::backend::tailscale::TailscaleState;
|
||||||
|
use crate::ui::action_centre::ActionCentre;
|
||||||
|
use crate::ui::widgets::{ExpandableBarItem, IconSlider};
|
||||||
|
use battery::State;
|
||||||
|
use glib::prelude::Cast;
|
||||||
|
use glib::subclass::prelude::ObjectSubclassIsExt;
|
||||||
|
use glib::{ControlFlow, clone};
|
||||||
|
use gtk::prelude::{
|
||||||
|
ApplicationExt, BoxExt, ButtonExt, DisplayExt, ListModelExt, MonitorExt, ToggleButtonExt,
|
||||||
|
WidgetExt,
|
||||||
|
};
|
||||||
|
use gtk::{
|
||||||
|
Adjustment, Align, Application, Button, Image, Label, Orientation, Revealer,
|
||||||
|
RevealerTransitionType, gdk,
|
||||||
|
};
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const BRIGHTNESS_ICONS: [&str; 3] = ["", "", ""];
|
||||||
|
const BATTERY_ICONS: [&str; 5] = ["", "", "", "", ""];
|
||||||
|
const WIFI_ICON_PATHS: [&str; 3] = [
|
||||||
|
"icons/wifi_low.svg",
|
||||||
|
"icons/wifi_medium.svg",
|
||||||
|
"icons/wifi_full.svg",
|
||||||
|
];
|
||||||
|
const VOLUME_ICON_PATHS: [&str; 4] = [
|
||||||
|
"icons/volume-off.svg",
|
||||||
|
"icons/volume-low.svg",
|
||||||
|
"icons/volume.svg",
|
||||||
|
"icons/volume-high.svg",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn build(
|
||||||
|
app: &Application,
|
||||||
|
action_centre: ActionCentre,
|
||||||
|
mut channels: BackendChannels,
|
||||||
|
monitor_idx: u32,
|
||||||
|
display: &gdk::Display,
|
||||||
|
) -> gtk::Box {
|
||||||
|
let utilities = gtk::Box::new(Orientation::Horizontal, 0);
|
||||||
|
utilities.set_halign(Align::End);
|
||||||
|
utilities.set_vexpand(true);
|
||||||
|
utilities.set_hexpand(true);
|
||||||
|
utilities.set_valign(Align::Fill);
|
||||||
|
utilities.add_css_class("utilities");
|
||||||
|
|
||||||
|
let monitors = display.monitors();
|
||||||
|
let monitor = if let Some(monitor) = monitors.item(monitor_idx)
|
||||||
|
&& let Ok(monitor) = monitor.downcast::<gdk::Monitor>()
|
||||||
|
&& let Some(connector) = monitor.connector()
|
||||||
|
{
|
||||||
|
String::from(connector)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let monitor_valid = !monitor.is_empty();
|
||||||
|
if monitor_valid {
|
||||||
|
let _ = channels
|
||||||
|
.br_tx()
|
||||||
|
.send(BrightnessMessage::Request(monitor.clone()))
|
||||||
|
.is_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let volume_adjustment = Adjustment::new(60.0, 1.0, 100.0, 1.0, 5.0, 0.0);
|
||||||
|
|
||||||
|
let volume = IconSlider::new(Some(volume_adjustment));
|
||||||
|
volume.imp().main_icon.set_icon_path(VOLUME_ICON_PATHS[2]);
|
||||||
|
volume.imp().second_icon.set_icon_name("bluetooth-symbolic");
|
||||||
|
volume.imp().second_icon.add_child_css_class("bt-icon");
|
||||||
|
|
||||||
|
let media_tx = channels.media_tx();
|
||||||
|
let changed_progr = Rc::new(Cell::new(false));
|
||||||
|
let changed_progr_clone = changed_progr.clone();
|
||||||
|
volume.connect_value_changed(move |slider, button, _| {
|
||||||
|
if !changed_progr_clone.get() {
|
||||||
|
let _ = media_tx.send(MediaCommand::Cmd(
|
||||||
|
slider.value() as u8,
|
||||||
|
button.is_active(),
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let media_tx = channels.media_tx();
|
||||||
|
volume.connect_clicked(move |slider, button, _| {
|
||||||
|
if button.is_active() {
|
||||||
|
let _ = media_tx.send(MediaCommand::Cmd(slider.value() as u8, true, false));
|
||||||
|
} else {
|
||||||
|
let _ = media_tx.send(MediaCommand::Cmd(slider.value() as u8, false, false));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let brightness_adjustment = Adjustment::new(60.0, 1.0, 100.0, 1.0, 5.0, 0.0);
|
||||||
|
let brightness = IconSlider::new(Some(brightness_adjustment));
|
||||||
|
brightness.imp().main_icon.set_font_icon("");
|
||||||
|
|
||||||
|
let br_tx = channels.br_tx();
|
||||||
|
brightness.connect_value_changed(clone!(
|
||||||
|
#[strong]
|
||||||
|
monitor,
|
||||||
|
move |slider, _button, _| {
|
||||||
|
if monitor_valid {
|
||||||
|
let slider_val = slider.value().max(1.0);
|
||||||
|
let _ = br_tx.send(BrightnessMessage::Value(monitor.clone(), slider_val as u8));
|
||||||
|
let idx =
|
||||||
|
((slider_val / 100.0) * (BRIGHTNESS_ICONS.len() - 1) as f64 + 0.5) as usize;
|
||||||
|
slider.imp().main_icon.set_font_icon(BRIGHTNESS_ICONS[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let br_tx = channels.br_tx();
|
||||||
|
brightness.connect_clicked(clone!(
|
||||||
|
#[strong]
|
||||||
|
monitor,
|
||||||
|
move |slider, _button, _| {
|
||||||
|
if monitor_valid {
|
||||||
|
let _ = br_tx.send(BrightnessMessage::Value(monitor.clone(), 60));
|
||||||
|
slider.set_value(60.0);
|
||||||
|
let idx = ((60.0 / 100.0) * (BRIGHTNESS_ICONS.len() - 1) as f64 + 0.5) as usize;
|
||||||
|
slider.imp().main_icon.set_font_icon(BRIGHTNESS_ICONS[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
let caffeine_button = Button::with_label(" ");
|
||||||
|
caffeine_button.add_css_class("caffeine");
|
||||||
|
let hypridle = channels.hypridle();
|
||||||
|
caffeine_button.connect_clicked(move |button| {
|
||||||
|
let mut child = hypridle.lock().expect("Couldn't get lock on hypridle");
|
||||||
|
if child.is_none() {
|
||||||
|
if let Ok(new_child) = Command::new("hypridle").spawn() {
|
||||||
|
let _ = child.insert(new_child);
|
||||||
|
button.remove_css_class("on")
|
||||||
|
}
|
||||||
|
} else if child.is_some()
|
||||||
|
&& let Ok(exit) = child.as_mut().unwrap().try_wait()
|
||||||
|
&& let Some(_) = exit
|
||||||
|
{
|
||||||
|
if let Ok(new_child) = Command::new("hypridle").spawn() {
|
||||||
|
let _ = child.insert(new_child);
|
||||||
|
button.remove_css_class("on")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Ok(_) = child.as_mut().unwrap().kill() {
|
||||||
|
let _ = child.take();
|
||||||
|
button.add_css_class("on");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stats = ExpandableBarItem::new("");
|
||||||
|
stats.add_css_class("stats");
|
||||||
|
stats.set_spacing(0);
|
||||||
|
stats.set_vexpand(true);
|
||||||
|
stats.set_valign(Align::Fill);
|
||||||
|
|
||||||
|
let cpu_box = gtk::Box::new(Orientation::Horizontal, 8);
|
||||||
|
let cpu_icon = Label::new(Some(""));
|
||||||
|
cpu_icon.add_css_class("stat-icon");
|
||||||
|
cpu_box.append(&cpu_icon);
|
||||||
|
let cpu_label = Label::new(None);
|
||||||
|
cpu_box.append(&cpu_label);
|
||||||
|
stats.set_button_child(Some(&cpu_box));
|
||||||
|
|
||||||
|
let stats_children = gtk::Box::new(Orientation::Horizontal, 6);
|
||||||
|
stats_children.add_css_class("expandable-content");
|
||||||
|
|
||||||
|
let ram_box = gtk::Box::new(Orientation::Horizontal, 6);
|
||||||
|
let ram_icon = Label::new(Some(""));
|
||||||
|
ram_icon.add_css_class("stat-icon");
|
||||||
|
ram_box.append(&ram_icon);
|
||||||
|
let ram_label = Label::new(None);
|
||||||
|
ram_box.append(&ram_label);
|
||||||
|
stats_children.append(&ram_box);
|
||||||
|
|
||||||
|
let disk_box = gtk::Box::new(Orientation::Horizontal, 9);
|
||||||
|
let disk_icon = Label::new(Some(""));
|
||||||
|
disk_icon.add_css_class("stat-icon");
|
||||||
|
disk_box.append(&disk_icon);
|
||||||
|
let disk_label = Label::new(None);
|
||||||
|
disk_box.append(&disk_label);
|
||||||
|
stats_children.append(&disk_box);
|
||||||
|
|
||||||
|
let gpu_box = gtk::Box::new(Orientation::Horizontal, 9);
|
||||||
|
let gpu_icon = Image::from_file("icons/gpu-card.svg");
|
||||||
|
gpu_icon.add_css_class("stat-icon");
|
||||||
|
gpu_box.append(&gpu_icon);
|
||||||
|
let gpu_label = Label::new(None);
|
||||||
|
gpu_box.append(&gpu_label);
|
||||||
|
stats_children.append(&gpu_box);
|
||||||
|
stats.set_second_child(stats_children);
|
||||||
|
|
||||||
|
let network_button = Button::new();
|
||||||
|
network_button.add_css_class("network");
|
||||||
|
let network_button_content = gtk::Box::new(Orientation::Horizontal, 0);
|
||||||
|
let network_icon = Image::from_file("../../icons/disconnected.svg");
|
||||||
|
network_icon.add_css_class("network-icon");
|
||||||
|
network_button_content.append(&network_icon);
|
||||||
|
let network_label = Label::new(None);
|
||||||
|
network_label.add_css_class("network-label");
|
||||||
|
network_label.set_visible(false);
|
||||||
|
network_button_content.append(&network_label);
|
||||||
|
network_button.set_child(Some(&network_button_content));
|
||||||
|
|
||||||
|
network_button.connect_clicked(clone!(
|
||||||
|
#[weak]
|
||||||
|
display,
|
||||||
|
move |_| {
|
||||||
|
if action_centre.window.is_visible() {
|
||||||
|
action_centre.hide_action_centre();
|
||||||
|
} else {
|
||||||
|
action_centre.show_action_centre(monitor_idx, &display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
let wg_button = Button::with_label("wg");
|
||||||
|
wg_button.add_css_class("vpn");
|
||||||
|
let net_tx = channels.net_tx();
|
||||||
|
wg_button.connect_clicked(move |_| {
|
||||||
|
let _ = net_tx.send(NetworkCommand::ToggleVpn(String::from("wg")));
|
||||||
|
});
|
||||||
|
|
||||||
|
let ts_button = Button::with_label("ts");
|
||||||
|
ts_button.add_css_class("vpn");
|
||||||
|
ts_button.connect_clicked(move |button| {
|
||||||
|
let _ = Command::new("tailscale")
|
||||||
|
.arg(if button.has_css_class("on") {
|
||||||
|
"down"
|
||||||
|
} else {
|
||||||
|
"up"
|
||||||
|
})
|
||||||
|
.output();
|
||||||
|
});
|
||||||
|
|
||||||
|
let tud_button = Button::with_label("tud");
|
||||||
|
tud_button.add_css_class("vpn");
|
||||||
|
let mvd_button = Button::with_label("mvd");
|
||||||
|
mvd_button.add_css_class("vpn");
|
||||||
|
|
||||||
|
let vpn_box = gtk::Box::new(Orientation::Horizontal, 0);
|
||||||
|
vpn_box.set_valign(Align::Fill);
|
||||||
|
vpn_box.set_vexpand(true);
|
||||||
|
vpn_box.append(&wg_button);
|
||||||
|
vpn_box.append(&ts_button);
|
||||||
|
//vpn_box.append(&tud_button);
|
||||||
|
//vpn_box.append(&mvd_button);
|
||||||
|
|
||||||
|
let vpn_revealer = Revealer::new();
|
||||||
|
vpn_revealer.set_transition_type(RevealerTransitionType::SlideRight);
|
||||||
|
vpn_revealer.set_transition_duration(500);
|
||||||
|
vpn_revealer.set_reveal_child(false);
|
||||||
|
vpn_revealer.set_child(Some(&vpn_box));
|
||||||
|
vpn_revealer.add_css_class("revealer");
|
||||||
|
|
||||||
|
let vpn_button = Button::with_label("");
|
||||||
|
vpn_button.add_css_class("vpn-icon");
|
||||||
|
|
||||||
|
let battery_widget = ExpandableBarItem::new("");
|
||||||
|
battery_widget.add_css_class("battery");
|
||||||
|
battery_widget.set_spacing(0);
|
||||||
|
battery_widget.set_vexpand(true);
|
||||||
|
battery_widget.set_valign(Align::Fill);
|
||||||
|
|
||||||
|
let battery_children = gtk::Box::new(Orientation::Horizontal, 0);
|
||||||
|
|
||||||
|
let wattage = Label::new(None);
|
||||||
|
battery_children.append(&wattage);
|
||||||
|
let remaining_time = Label::new(None);
|
||||||
|
battery_children.append(&remaining_time);
|
||||||
|
|
||||||
|
battery_widget.set_second_child(battery_children);
|
||||||
|
|
||||||
|
let time = ExpandableBarItem::new("");
|
||||||
|
time.add_css_class("time");
|
||||||
|
time.set_spacing(0);
|
||||||
|
time.set_vexpand(true);
|
||||||
|
time.set_valign(Align::Fill);
|
||||||
|
|
||||||
|
let time_hidden = Label::new(None);
|
||||||
|
time.set_second_child(time_hidden.clone());
|
||||||
|
|
||||||
|
let power_button = Button::with_label(" ");
|
||||||
|
power_button.add_css_class("power-button");
|
||||||
|
power_button.connect_clicked(clone!(
|
||||||
|
#[strong]
|
||||||
|
app,
|
||||||
|
move |_button| {
|
||||||
|
app.quit();
|
||||||
|
//let _ = Command::new("wlogout").spawn();
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
utilities.append(&volume);
|
||||||
|
utilities.append(&brightness);
|
||||||
|
utilities.append(&caffeine_button);
|
||||||
|
utilities.append(&stats);
|
||||||
|
utilities.append(&network_button);
|
||||||
|
utilities.append(&vpn_button);
|
||||||
|
utilities.append(&vpn_revealer);
|
||||||
|
//utilities.append(&systray_box);
|
||||||
|
utilities.append(&battery_widget);
|
||||||
|
utilities.append(&time);
|
||||||
|
utilities.append(&power_button);
|
||||||
|
|
||||||
|
vpn_button.connect_clicked(move |_button| {
|
||||||
|
vpn_revealer.set_reveal_child(!vpn_revealer.is_child_revealed());
|
||||||
|
});
|
||||||
|
|
||||||
|
glib::timeout_add_local(Duration::from_millis(1000), move || {
|
||||||
|
let timestamp = time_format::now().unwrap();
|
||||||
|
let time_str = time_format::strftime_local("%H:%M", timestamp).unwrap_or(String::new());
|
||||||
|
time.set_title(&time_str);
|
||||||
|
let time_sec =
|
||||||
|
time_format::strftime_local(":%S %Y-%m-%d", timestamp).unwrap_or(String::new());
|
||||||
|
time_hidden.set_label(&time_sec);
|
||||||
|
ControlFlow::Continue
|
||||||
|
});
|
||||||
|
|
||||||
|
let media_rx = channels.take_media_rx().unwrap();
|
||||||
|
let br_rx = channels.take_br_rx().unwrap();
|
||||||
|
let net_rx = channels.take_net_rx().unwrap();
|
||||||
|
let stats_rx = channels.take_stats_rx().unwrap();
|
||||||
|
let ts_rx = channels.take_ts_rx().unwrap();
|
||||||
|
let hypridle = channels.hypridle();
|
||||||
|
glib::timeout_add_local(Duration::from_millis(100), move || {
|
||||||
|
while let Ok(command) = media_rx.try_recv() {
|
||||||
|
match command {
|
||||||
|
MediaCommand::Cmd(vol, mute, bt) => {
|
||||||
|
changed_progr.set(true);
|
||||||
|
volume.set_value(vol as f64);
|
||||||
|
changed_progr.set(false);
|
||||||
|
volume.show_scale(!mute);
|
||||||
|
volume.imp().button.set_active(mute);
|
||||||
|
if mute {
|
||||||
|
volume.imp().button.add_css_class("muted");
|
||||||
|
volume.imp().second_icon.add_child_css_class("muted");
|
||||||
|
volume
|
||||||
|
.imp()
|
||||||
|
.main_icon
|
||||||
|
.set_icon_path("icons/volume-mute.svg");
|
||||||
|
} else {
|
||||||
|
volume.imp().button.remove_css_class("muted");
|
||||||
|
volume.imp().second_icon.remove_child_css_class("muted");
|
||||||
|
let idx = ((volume.value() / 100.0) * (VOLUME_ICON_PATHS.len() - 1) as f64
|
||||||
|
+ 0.5) as usize;
|
||||||
|
volume.imp().main_icon.set_icon_path(VOLUME_ICON_PATHS[idx]);
|
||||||
|
}
|
||||||
|
volume.imp().second_icon.set_visible(bt);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Ok(message) = br_rx.try_recv() {
|
||||||
|
match message {
|
||||||
|
BrightnessMessage::Value(mon, value) => {
|
||||||
|
if monitor.eq(&mon) {
|
||||||
|
brightness.set_value(value as f64);
|
||||||
|
let idx = ((value as f32 / 100.0) * (BRIGHTNESS_ICONS.len() - 1) as f32
|
||||||
|
+ 0.5) as usize;
|
||||||
|
brightness
|
||||||
|
.imp()
|
||||||
|
.main_icon
|
||||||
|
.set_font_icon(BRIGHTNESS_ICONS[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(mut hypridle) = hypridle.try_lock() {
|
||||||
|
if hypridle.is_none() {
|
||||||
|
caffeine_button.add_css_class("on");
|
||||||
|
} else {
|
||||||
|
if let Ok(exit) = hypridle.as_mut().unwrap().try_wait()
|
||||||
|
&& exit.is_some()
|
||||||
|
{
|
||||||
|
caffeine_button.add_css_class("on");
|
||||||
|
} else {
|
||||||
|
caffeine_button.remove_css_class("on");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Ok(result) = net_rx.try_recv() {
|
||||||
|
trace!(&LOGGER, "NetworkResult: {:?}", result);
|
||||||
|
match result {
|
||||||
|
NetworkState::ChangeNetwork(cfg) => {
|
||||||
|
match cfg {
|
||||||
|
Some(network) => {
|
||||||
|
match network.network_type {
|
||||||
|
NetworkType::Wifi => {
|
||||||
|
//let idx = ((20.0 / 100.0) * (WIFI_ICON_PATHS.len() - 1) as f32 + 0.5) as usize;
|
||||||
|
let idx = ((network.signal as f32 / 100.0)
|
||||||
|
* (WIFI_ICON_PATHS.len() - 1) as f32
|
||||||
|
+ 0.5)
|
||||||
|
as usize;
|
||||||
|
network_icon.set_from_file(Some(WIFI_ICON_PATHS[idx]));
|
||||||
|
network_label.set_label(&network.name);
|
||||||
|
network_label.set_visible(true);
|
||||||
|
}
|
||||||
|
NetworkType::Wired => {
|
||||||
|
network_icon.set_from_file(Some("icons/ethernet.svg"));
|
||||||
|
network_label.set_visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
network_icon.set_from_file(Some("icons/disconnected.svg"));
|
||||||
|
network_label.set_visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NetworkState::VpnState { connected, profile } => match profile.as_str() {
|
||||||
|
"wg" => {
|
||||||
|
if connected {
|
||||||
|
wg_button.add_css_class("on");
|
||||||
|
} else {
|
||||||
|
wg_button.remove_css_class("on");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Ok(tailscale_state) = ts_rx.try_recv() {
|
||||||
|
match tailscale_state {
|
||||||
|
TailscaleState::Stopped => {
|
||||||
|
ts_button.remove_css_class("on");
|
||||||
|
ts_button.remove_css_class("starting");
|
||||||
|
ts_button.remove_css_class("unknown");
|
||||||
|
}
|
||||||
|
TailscaleState::Starting => {
|
||||||
|
ts_button.remove_css_class("on");
|
||||||
|
ts_button.add_css_class("starting");
|
||||||
|
ts_button.remove_css_class("unknown");
|
||||||
|
}
|
||||||
|
TailscaleState::Running => {
|
||||||
|
ts_button.add_css_class("on");
|
||||||
|
ts_button.remove_css_class("starting");
|
||||||
|
ts_button.remove_css_class("unknown");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
ts_button.remove_css_class("on");
|
||||||
|
ts_button.remove_css_class("starting");
|
||||||
|
ts_button.add_css_class("unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wg_button.has_css_class("on") || ts_button.has_css_class("on") {
|
||||||
|
vpn_button.add_css_class("on");
|
||||||
|
} else {
|
||||||
|
vpn_button.remove_css_class("on");
|
||||||
|
}
|
||||||
|
while let Ok(stats) = stats_rx.try_recv() {
|
||||||
|
cpu_label.set_label(&format!("{}%", stats.cpu));
|
||||||
|
ram_label.set_label(&format!("{}%", stats.ram));
|
||||||
|
for disk in stats.disks {
|
||||||
|
if disk.0.eq("/") {
|
||||||
|
disk_label.set_label(&format!("{}%", disk.1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for gpu in stats.gpus {
|
||||||
|
gpu_label.set_label(&format!("{}%", gpu.1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_battery_css_classes(&battery_widget, stats.battery_state, stats.battery_charge);
|
||||||
|
let idx = ((stats.battery_charge as f32 / 100.0) * (BATTERY_ICONS.len() - 1) as f32
|
||||||
|
+ 0.5) as usize;
|
||||||
|
let idx = idx.min(BATTERY_ICONS.len() - 1);
|
||||||
|
battery_widget.set_title(&format!("{} {}%", BATTERY_ICONS[idx], stats.battery_charge));
|
||||||
|
wattage.set_label(&format!(" {:.1}W", stats.battery_draw));
|
||||||
|
remaining_time.set_label(&format!(" {}h", format_remaining(stats.battery_time)));
|
||||||
|
}
|
||||||
|
ControlFlow::Continue
|
||||||
|
});
|
||||||
|
utilities
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_remaining(ms: u64) -> String {
|
||||||
|
let total_minutes = ms / 60_000;
|
||||||
|
let hours = total_minutes / 60;
|
||||||
|
let minutes = total_minutes % 60;
|
||||||
|
|
||||||
|
format!("{hours}:{minutes:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_battery_css_classes(battery_widget: &ExpandableBarItem, state: State, percentage: u8) {
|
||||||
|
if state == State::Charging {
|
||||||
|
battery_widget.add_css_class("charging");
|
||||||
|
} else {
|
||||||
|
battery_widget.remove_css_class("charging");
|
||||||
|
battery_widget.remove_css_class("critical");
|
||||||
|
battery_widget.remove_css_class("low");
|
||||||
|
battery_widget.remove_css_class("medium");
|
||||||
|
match percentage {
|
||||||
|
0..=10 => {
|
||||||
|
battery_widget.add_css_class("critical");
|
||||||
|
}
|
||||||
|
11..=20 => {
|
||||||
|
battery_widget.add_css_class("low");
|
||||||
|
}
|
||||||
|
21..=50 => {
|
||||||
|
battery_widget.add_css_class("medium");
|
||||||
|
}
|
||||||
|
51..=100 => {}
|
||||||
|
101.. => {
|
||||||
|
battery_widget.set_title("<invalid>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
646
src/ui/widgets.rs
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
use glib::ParamSpec;
|
||||||
|
use glib::object::IsA;
|
||||||
|
use glib::prelude::ObjectExt;
|
||||||
|
use glib::subclass::prelude::*;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Adjustment, Button, Label, Scale, ToggleButton, Widget};
|
||||||
|
|
||||||
|
mod imp_label_entry {
|
||||||
|
use glib::subclass::object::ObjectImpl;
|
||||||
|
use glib::subclass::prelude::*;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::subclass::box_::BoxImpl;
|
||||||
|
use gtk::subclass::prelude::*;
|
||||||
|
use gtk::{Entry, Label, Orientation};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct LabelEntry {
|
||||||
|
pub label: std::cell::OnceCell<Label>,
|
||||||
|
pub entry: std::cell::OnceCell<Entry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for LabelEntry {
|
||||||
|
const NAME: &'static str = "EntryLabel";
|
||||||
|
type Type = super::LabelEntry;
|
||||||
|
type ParentType = gtk::Box;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for LabelEntry {
|
||||||
|
fn constructed(&self) {
|
||||||
|
self.parent_constructed();
|
||||||
|
let obj = self.obj();
|
||||||
|
obj.set_orientation(Orientation::Horizontal);
|
||||||
|
obj.set_spacing(4);
|
||||||
|
|
||||||
|
let label = Label::new(None);
|
||||||
|
let entry = Entry::new();
|
||||||
|
obj.append(&label);
|
||||||
|
obj.append(&entry);
|
||||||
|
|
||||||
|
self.label.set(label.clone()).unwrap();
|
||||||
|
self.entry.set(entry.clone()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetImpl for LabelEntry {}
|
||||||
|
impl BoxImpl for LabelEntry {}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp_icon_label {
|
||||||
|
use crate::ui::widgets::Icon;
|
||||||
|
use glib::Properties;
|
||||||
|
use glib::property::PropertySet;
|
||||||
|
use glib::subclass::object::ObjectImpl;
|
||||||
|
use glib::subclass::prelude::*;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::subclass::box_::BoxImpl;
|
||||||
|
use gtk::subclass::prelude::*;
|
||||||
|
use gtk::{Label, Orientation};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
#[derive(Default, Properties)]
|
||||||
|
#[properties(wrapper_type = super::IconLabel)]
|
||||||
|
pub struct IconLabel {
|
||||||
|
pub icon: std::cell::OnceCell<Icon>,
|
||||||
|
pub label: std::cell::OnceCell<Label>,
|
||||||
|
#[property(get, set = Self::set_icon_path, name = "icon-path")]
|
||||||
|
icon_path: RefCell<String>,
|
||||||
|
#[property(get, set = Self::set_icon_name, name = "icon-name")]
|
||||||
|
icon_name: RefCell<String>,
|
||||||
|
#[property(get, set = Self::set_font_icon, name = "font-icon")]
|
||||||
|
font_icon: RefCell<String>,
|
||||||
|
#[property(get, set = Self::set_label, name = "label")]
|
||||||
|
label_text: RefCell<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for IconLabel {
|
||||||
|
const NAME: &'static str = "JBIconLabel";
|
||||||
|
type Type = super::IconLabel;
|
||||||
|
type ParentType = gtk::Box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::derived_properties]
|
||||||
|
impl ObjectImpl for IconLabel {
|
||||||
|
fn constructed(&self) {
|
||||||
|
self.parent_constructed();
|
||||||
|
let obj = self.obj();
|
||||||
|
obj.set_orientation(Orientation::Horizontal);
|
||||||
|
obj.set_spacing(4);
|
||||||
|
|
||||||
|
let icon = Icon::new();
|
||||||
|
obj.append(&icon);
|
||||||
|
|
||||||
|
let label = Label::new(None);
|
||||||
|
obj.append(&label);
|
||||||
|
|
||||||
|
self.icon.set(icon.clone()).unwrap();
|
||||||
|
self.label.set(label.clone()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetImpl for IconLabel {}
|
||||||
|
impl BoxImpl for IconLabel {}
|
||||||
|
|
||||||
|
impl IconLabel {
|
||||||
|
fn set_icon_path(&self, path: String) {
|
||||||
|
self.icon_path.set(path.clone());
|
||||||
|
self.icon.get().unwrap().set_icon_path(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_icon_name(&self, name: String) {
|
||||||
|
self.icon_name.set(name.clone());
|
||||||
|
self.icon.get().unwrap().set_icon_name(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_font_icon(&self, icon: &str) {
|
||||||
|
self.font_icon.set(icon.to_string());
|
||||||
|
self.icon.get().unwrap().set_font_icon(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_label(&self, text: &str) {
|
||||||
|
self.label_text.set(text.to_string());
|
||||||
|
self.label.get().unwrap().set_text(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp_icon {
|
||||||
|
use glib::Properties;
|
||||||
|
use glib::object::ObjectExt;
|
||||||
|
use glib::property::PropertySet;
|
||||||
|
use glib::subclass::InitializingObject;
|
||||||
|
use glib::subclass::object::ObjectImpl;
|
||||||
|
use glib::subclass::prelude::*;
|
||||||
|
use gtk::prelude::WidgetExt;
|
||||||
|
use gtk::subclass::box_::BoxImpl;
|
||||||
|
use gtk::subclass::prelude::*;
|
||||||
|
use gtk::{CompositeTemplate, Image, Label, TemplateChild};
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
|
||||||
|
#[derive(CompositeTemplate, Default, Properties)]
|
||||||
|
#[properties(wrapper_type = super::Icon)]
|
||||||
|
#[template(resource = "/net/jmb19905/jmb-bar/icon.ui")]
|
||||||
|
pub struct Icon {
|
||||||
|
#[template_child]
|
||||||
|
pub image: TemplateChild<Image>,
|
||||||
|
#[template_child]
|
||||||
|
pub label: TemplateChild<Label>,
|
||||||
|
#[property(get, set = Self::add_child_css_class, name = "child-css-class")]
|
||||||
|
child_css_class: RefCell<String>,
|
||||||
|
#[property(get, set = Self::set_icon_size, minimum = 1, maximum = 255, default = 16, name = "pixel-size")]
|
||||||
|
pixel_size: Cell<i32>,
|
||||||
|
#[property(get, set = Self::set_icon_path, name = "icon-path")]
|
||||||
|
icon_path: RefCell<String>,
|
||||||
|
#[property(get, set = Self::set_icon_name, name = "icon-name")]
|
||||||
|
icon_name: RefCell<String>,
|
||||||
|
#[property(get, set = Self::set_font_icon, name = "font-icon")]
|
||||||
|
font_icon: RefCell<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for Icon {
|
||||||
|
const NAME: &'static str = "JBIcon";
|
||||||
|
type Type = super::Icon;
|
||||||
|
type ParentType = gtk::Box;
|
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) {
|
||||||
|
klass.bind_template();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(obj: &InitializingObject<Self>) {
|
||||||
|
obj.init_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::derived_properties]
|
||||||
|
impl ObjectImpl for Icon {}
|
||||||
|
impl WidgetImpl for Icon {}
|
||||||
|
impl BoxImpl for Icon {}
|
||||||
|
|
||||||
|
impl Icon {
|
||||||
|
fn add_child_css_class(&self, class: &str) {
|
||||||
|
self.child_css_class.set(class.to_string());
|
||||||
|
if self.label.is_bound() {
|
||||||
|
self.label.add_css_class(class);
|
||||||
|
}
|
||||||
|
if self.image.is_bound() {
|
||||||
|
self.image.add_css_class(class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_icon_size(&self, size: i32) {
|
||||||
|
self.pixel_size.set(size);
|
||||||
|
|
||||||
|
if self.image.is_bound() {
|
||||||
|
self.image.set_pixel_size(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_icon_path(&self, path: String) {
|
||||||
|
self.image.set_from_file(Some(path));
|
||||||
|
self.label.set_visible(false);
|
||||||
|
self.image.set_visible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_icon_name(&self, name: String) {
|
||||||
|
self.image.set_icon_name(Some(&name));
|
||||||
|
self.label.set_visible(false);
|
||||||
|
self.image.set_visible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_font_icon(&self, icon: &str) {
|
||||||
|
self.label.set_label(icon);
|
||||||
|
self.label.set_visible(true);
|
||||||
|
self.image.set_visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp_icon_slider {
|
||||||
|
use glib::subclass::InitializingObject;
|
||||||
|
use glib::subclass::object::ObjectImpl;
|
||||||
|
use glib::subclass::prelude::ObjectSubclass;
|
||||||
|
use gtk::subclass::box_::BoxImpl;
|
||||||
|
use gtk::subclass::prelude::*;
|
||||||
|
use gtk::{CompositeTemplate, TemplateChild, ToggleButton};
|
||||||
|
|
||||||
|
#[derive(CompositeTemplate, Default)]
|
||||||
|
#[template(resource = "/net/jmb19905/jmb-bar/icon_slider.ui")]
|
||||||
|
pub struct IconSlider {
|
||||||
|
#[template_child]
|
||||||
|
pub button: TemplateChild<ToggleButton>,
|
||||||
|
#[template_child]
|
||||||
|
pub main_icon: TemplateChild<super::Icon>,
|
||||||
|
#[template_child]
|
||||||
|
pub second_icon: TemplateChild<super::Icon>,
|
||||||
|
#[template_child]
|
||||||
|
pub slider: TemplateChild<gtk::Scale>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for IconSlider {
|
||||||
|
const NAME: &'static str = "JBIconSlider";
|
||||||
|
type Type = super::IconSlider;
|
||||||
|
type ParentType = gtk::Box;
|
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) {
|
||||||
|
klass.bind_template();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(obj: &InitializingObject<Self>) {
|
||||||
|
obj.init_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for IconSlider {}
|
||||||
|
|
||||||
|
impl WidgetImpl for IconSlider {}
|
||||||
|
|
||||||
|
impl BoxImpl for IconSlider {}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp_bar_item {
|
||||||
|
use glib::Properties;
|
||||||
|
use glib::object::ObjectExt;
|
||||||
|
use glib::property::PropertySet;
|
||||||
|
use glib::subclass::InitializingObject;
|
||||||
|
use glib::subclass::object::ObjectImpl;
|
||||||
|
use glib::subclass::prelude::*;
|
||||||
|
use gtk::prelude::{ButtonExt, WidgetExt};
|
||||||
|
use gtk::subclass::box_::BoxImpl;
|
||||||
|
use gtk::subclass::prelude::*;
|
||||||
|
use gtk::{Button, CompositeTemplate, EventControllerMotion, Revealer, TemplateChild, Widget};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
#[derive(CompositeTemplate, Default, Properties)]
|
||||||
|
#[properties(wrapper_type = super::ExpandableBarItem)]
|
||||||
|
#[template(resource = "/net/jmb19905/jmb-bar/expandable_bar_item.ui")]
|
||||||
|
pub struct ExpandableBarItem {
|
||||||
|
#[template_child]
|
||||||
|
pub main_button: TemplateChild<Button>,
|
||||||
|
#[template_child]
|
||||||
|
pub revealer: TemplateChild<Revealer>,
|
||||||
|
#[property(get, set = Self::set_main_child, name = "main-child")]
|
||||||
|
pub main_child: RefCell<Option<Widget>>,
|
||||||
|
#[property(get, set = Self::set_second_child, name = "second-child")]
|
||||||
|
pub second_child: RefCell<Option<Widget>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for ExpandableBarItem {
|
||||||
|
const NAME: &'static str = "JBExpandableBarItem";
|
||||||
|
type Type = super::ExpandableBarItem;
|
||||||
|
type ParentType = gtk::Box;
|
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) {
|
||||||
|
klass.bind_template();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(obj: &InitializingObject<Self>) {
|
||||||
|
obj.init_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::derived_properties]
|
||||||
|
impl ObjectImpl for ExpandableBarItem {
|
||||||
|
fn constructed(&self) {
|
||||||
|
self.parent_constructed();
|
||||||
|
|
||||||
|
let obj = self.obj();
|
||||||
|
let motion = EventControllerMotion::new();
|
||||||
|
|
||||||
|
let revealer_enter = self.revealer.clone();
|
||||||
|
let button_enter = self.main_button.clone();
|
||||||
|
|
||||||
|
motion.connect_enter(move |_, _, _| {
|
||||||
|
revealer_enter.set_reveal_child(true);
|
||||||
|
button_enter.add_css_class("hovered");
|
||||||
|
});
|
||||||
|
|
||||||
|
let revealer_leave = self.revealer.clone();
|
||||||
|
let button_leave = self.main_button.clone();
|
||||||
|
|
||||||
|
motion.connect_leave(move |_| {
|
||||||
|
revealer_leave.set_reveal_child(false);
|
||||||
|
button_leave.remove_css_class("hovered");
|
||||||
|
});
|
||||||
|
|
||||||
|
obj.add_controller(motion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetImpl for ExpandableBarItem {}
|
||||||
|
|
||||||
|
impl BoxImpl for ExpandableBarItem {}
|
||||||
|
|
||||||
|
impl ExpandableBarItem {
|
||||||
|
fn set_main_child(&self, widget: Option<&Widget>) {
|
||||||
|
self.main_child.set(widget.cloned());
|
||||||
|
self.main_button.set_child(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_second_child(&self, widget: Option<&Widget>) {
|
||||||
|
self.second_child.set(widget.cloned());
|
||||||
|
self.revealer.set_child(widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp_wifi_widget {
|
||||||
|
use glib::subclass::InitializingObject;
|
||||||
|
use glib::subclass::prelude::ObjectSubclass;
|
||||||
|
use gtk::subclass::prelude::*;
|
||||||
|
use gtk::{Button, CompositeTemplate, Label, PasswordEntry, Spinner, Stack, TemplateChild};
|
||||||
|
|
||||||
|
#[derive(CompositeTemplate, Default)]
|
||||||
|
#[template(resource = "/net/jmb19905/jmb-bar/wifi_widget.ui")]
|
||||||
|
pub struct WifiWidget {
|
||||||
|
#[template_child]
|
||||||
|
pub wifi_icon: TemplateChild<super::Icon>,
|
||||||
|
#[template_child]
|
||||||
|
pub spinner: TemplateChild<Spinner>,
|
||||||
|
#[template_child]
|
||||||
|
pub stack: TemplateChild<Stack>,
|
||||||
|
#[template_child]
|
||||||
|
pub ssid: TemplateChild<Label>,
|
||||||
|
#[template_child]
|
||||||
|
pub password: TemplateChild<PasswordEntry>,
|
||||||
|
#[template_child]
|
||||||
|
pub known: TemplateChild<super::Icon>,
|
||||||
|
#[template_child]
|
||||||
|
pub secure: TemplateChild<super::Icon>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for WifiWidget {
|
||||||
|
const NAME: &'static str = "JBWifiWidget";
|
||||||
|
type Type = super::WifiWidget;
|
||||||
|
type ParentType = Button;
|
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) {
|
||||||
|
klass.bind_template();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(obj: &InitializingObject<Self>) {
|
||||||
|
obj.init_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for WifiWidget {}
|
||||||
|
impl WidgetImpl for WifiWidget {}
|
||||||
|
impl ButtonImpl for WifiWidget {}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct LabelEntry(ObjectSubclass<imp_label_entry::LabelEntry>)
|
||||||
|
@extends gtk::Box, Widget,
|
||||||
|
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LabelEntry {
|
||||||
|
pub fn new(title: &str) -> Self {
|
||||||
|
let obj: Self = glib::Object::new();
|
||||||
|
|
||||||
|
if let Some(label) = obj.clone().downcast::<LabelEntry>().unwrap().label() {
|
||||||
|
label.set_label(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> Option<Label> {
|
||||||
|
let imp = self.imp();
|
||||||
|
|
||||||
|
if let Some(label) = imp.label.get() {
|
||||||
|
return Some(label.clone());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct IconLabel(ObjectSubclass<imp_icon_label::IconLabel>)
|
||||||
|
@extends gtk::Box, Widget,
|
||||||
|
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IconLabel {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let obj: Self = glib::Object::new();
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct Icon(ObjectSubclass<imp_icon::Icon>)
|
||||||
|
@extends gtk::Box, Widget,
|
||||||
|
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Icon {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let obj: Self = glib::Object::new();
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
pub fn from_font_icon(icon: &str) -> Self {
|
||||||
|
let obj = Self::new();
|
||||||
|
obj.set_font_icon(icon);
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_icon_path(
|
||||||
|
path: Option<impl AsRef<std::path::Path> + std::borrow::Borrow<str>>,
|
||||||
|
) -> Self {
|
||||||
|
let obj = Self::new();
|
||||||
|
if let Some(path) = path {
|
||||||
|
obj.set_icon_path(path);
|
||||||
|
}
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_icon_name(name: &str) -> Self {
|
||||||
|
let obj = Self::new();
|
||||||
|
obj.set_icon_name(name);
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_child_css_class(&self, class: &str) {
|
||||||
|
self.imp().image.add_css_class(class);
|
||||||
|
self.imp().label.add_css_class(class);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_child_css_class(&self, class: &str) {
|
||||||
|
self.imp().image.remove_css_class(class);
|
||||||
|
self.imp().label.remove_css_class(class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct IconSlider(ObjectSubclass<imp_icon_slider::IconSlider>)
|
||||||
|
@extends gtk::Box, Widget,
|
||||||
|
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IconSlider {
|
||||||
|
pub fn new(adjustment: Option<Adjustment>) -> Self {
|
||||||
|
let obj: Self = glib::Object::new();
|
||||||
|
if let Some(adj) = adjustment {
|
||||||
|
obj.imp().slider.set_adjustment(&adj);
|
||||||
|
}
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_second_icon(&self, val: bool) {
|
||||||
|
self.imp().second_icon.set_visible(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_value_changed<A>(&self, action: A)
|
||||||
|
where
|
||||||
|
A: Fn(&IconSlider, &ToggleButton, &Scale) + 'static,
|
||||||
|
{
|
||||||
|
let clone = self.clone();
|
||||||
|
self.imp().slider.connect_value_changed(move |scale| {
|
||||||
|
action(&clone, &clone.imp().button, scale);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_clicked<A>(&self, action: A)
|
||||||
|
where
|
||||||
|
A: Fn(&IconSlider, &ToggleButton, &Scale) + 'static,
|
||||||
|
{
|
||||||
|
let clone = self.clone();
|
||||||
|
self.imp().button.connect_clicked(move |button| {
|
||||||
|
action(&clone, button, &clone.imp().slider);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> f64 {
|
||||||
|
self.imp().slider.value()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_value(&self, val: f64) {
|
||||||
|
self.imp().slider.set_value(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_scale(&self, val: bool) {
|
||||||
|
self.imp().slider.set_visible(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct ExpandableBarItem(ObjectSubclass<imp_bar_item::ExpandableBarItem>)
|
||||||
|
@extends gtk::Box, Widget,
|
||||||
|
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExpandableBarItem {
|
||||||
|
pub fn new(title: &str) -> Self {
|
||||||
|
let obj: Self = glib::Object::new();
|
||||||
|
|
||||||
|
obj.set_title(title);
|
||||||
|
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_title(&self, title: &str) {
|
||||||
|
self.imp().main_button.set_label(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_icon(&self, icon_name: &str) {
|
||||||
|
self.imp().main_button.set_label(icon_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_button_child(&self, child: Option<&impl IsA<Widget>>) {
|
||||||
|
self.imp().main_button.set_child(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_clicked<F>(&self, action: F)
|
||||||
|
where
|
||||||
|
F: Fn(&Button) + 'static,
|
||||||
|
{
|
||||||
|
self.imp().main_button.connect_clicked(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct WifiWidget(ObjectSubclass<imp_wifi_widget::WifiWidget>)
|
||||||
|
@extends Button, Widget,
|
||||||
|
@implements gtk::Actionable, gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WifiWidget {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let obj: Self = glib::Object::new();
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_ssid(&self, ssid: &str) {
|
||||||
|
self.imp().ssid.set_label(ssid);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ssid(&self) -> glib::GString {
|
||||||
|
self.imp().ssid.get().text()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_secured(&self, secured: bool) {
|
||||||
|
if secured {
|
||||||
|
let _ = &self.imp().secure.set_icon_name("channel-secure-symbolic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_known(&self, known: bool) {
|
||||||
|
if known {
|
||||||
|
let _ = &self.imp().known.set_icon_name("user-bookmarks-symbolic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grab_focus_password_entry(&self) -> bool {
|
||||||
|
self.imp().password.grab_focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_spinner(&self) {
|
||||||
|
self.imp().spinner.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_spinner(&self) {
|
||||||
|
self.imp().spinner.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_stack_child(&self, name: &str) {
|
||||||
|
self.imp().stack.set_visible_child_name(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_password_entry(&self) {
|
||||||
|
self.set_stack_child("pass");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_ssid(&self) {
|
||||||
|
self.set_stack_child("ssid");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_wifi_icon_from_file(&self, icon_path: &str) {
|
||||||
|
self.imp().wifi_icon.set_icon_path(icon_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_wifi_icon_from_name(&self, icon: &str) {
|
||||||
|
self.imp().wifi_icon.set_icon_name(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_activate_pass<F>(&self, action: F)
|
||||||
|
where
|
||||||
|
F: Fn(>k::PasswordEntry) + 'static,
|
||||||
|
{
|
||||||
|
self.imp().password.connect_activate(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_has_focus_pass<F>(&self, action: F)
|
||||||
|
where
|
||||||
|
F: Fn(>k::PasswordEntry, &ParamSpec) + 'static,
|
||||||
|
{
|
||||||
|
self.imp()
|
||||||
|
.password
|
||||||
|
.connect_notify_local(Some("has-focus"), action);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/ui/workspaces_panel.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
use crate::LOGGER;
|
||||||
|
use crate::backend::window_manager_hypr::HyprlandComms;
|
||||||
|
use gtk::prelude::{BoxExt, ButtonExt, WidgetExt};
|
||||||
|
use gtk::{Align, Button, Orientation};
|
||||||
|
use hyprland::data::Workspace;
|
||||||
|
use hyprland::prelude::HyprDataActive;
|
||||||
|
use jmb_logger_rs::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub(crate) fn build(buttons: &mut HashMap<i32, Button>, monitor: Option<u32>) -> (gtk::Box, Button) {
|
||||||
|
let workspaces = gtk::Box::new(Orientation::Horizontal, 0);
|
||||||
|
workspaces.set_halign(Align::Start);
|
||||||
|
workspaces.set_valign(Align::Center);
|
||||||
|
workspaces.set_vexpand(true);
|
||||||
|
workspaces.set_valign(Align::Fill);
|
||||||
|
workspaces.set_hexpand(true);
|
||||||
|
workspaces.add_css_class("workspaces");
|
||||||
|
|
||||||
|
for workspace in 1..10 {
|
||||||
|
let button = Button::with_label(&format!("{workspace}"));
|
||||||
|
button.set_vexpand(true);
|
||||||
|
button.add_css_class("workspace-button");
|
||||||
|
button.set_visible(false);
|
||||||
|
|
||||||
|
let initial_workspace = Workspace::get_active()
|
||||||
|
.ok()
|
||||||
|
.map(|workspace| workspace.id().unwrap_or(1) as i32)
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
let is_active_ws_on_current_monitor = Workspace::get_active()
|
||||||
|
.ok()
|
||||||
|
.map(|ws| ws.monitor_id)
|
||||||
|
.flatten()
|
||||||
|
.is_some_and(|id| monitor.is_some_and(|other_id| id == other_id as i128));
|
||||||
|
|
||||||
|
if workspace == initial_workspace && is_active_ws_on_current_monitor {
|
||||||
|
button.add_css_class("active");
|
||||||
|
button.set_visible(true);
|
||||||
|
}
|
||||||
|
if HyprlandComms::workspace_added(workspace, monitor.map(|monitor| monitor as i128)) {
|
||||||
|
button.set_visible(true);
|
||||||
|
}
|
||||||
|
button.connect_clicked(move |_button| {
|
||||||
|
if let Err(err) = HyprlandComms::dispatch(&format!("hl.dsp.focus({{ workspace = {workspace} }})")) {
|
||||||
|
warn!(&LOGGER, "Couldn't change workspace focus {}", err.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
workspaces.append(&button);
|
||||||
|
buttons.insert(workspace, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
let submap_button = Button::new();
|
||||||
|
submap_button.add_css_class("submap");
|
||||||
|
submap_button.connect_clicked(|_| {
|
||||||
|
if let Err(err) = HyprlandComms::dispatch("hl.dsp.submap('reset')") {
|
||||||
|
warn!(&LOGGER, "Couldn't reset submap {}", err.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
workspaces.append(&submap_button);
|
||||||
|
|
||||||
|
(workspaces, submap_button)
|
||||||
|
}
|
||||||
68
style/_widgets.scss
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
@use "colors" as *;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: $orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-slider {
|
||||||
|
color: $foreground;
|
||||||
|
padding: 0 6px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-button {
|
||||||
|
color: $orange;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0 8px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-button:hover {
|
||||||
|
background: $background1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-slider scale trough {
|
||||||
|
all: unset;
|
||||||
|
background-color: $gray;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 5px;
|
||||||
|
min-width: 50px;
|
||||||
|
margin-left: 2px;
|
||||||
|
|
||||||
|
highlight {
|
||||||
|
background-color: $orange;
|
||||||
|
color: $background;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-button:hover {
|
||||||
|
background: $background1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-button:focus {
|
||||||
|
border: 1px solid $gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-button.connected {
|
||||||
|
border: 1px solid $orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner {
|
||||||
|
color: $orange;
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry {
|
||||||
|
border: 1px solid $background1;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password {
|
||||||
|
all: unset;
|
||||||
|
}
|
||||||
13
style/action_centre.scss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@use "colors" as *;
|
||||||
|
|
||||||
|
.action-centre {
|
||||||
|
background: $background;
|
||||||
|
opacity: 0.9;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid $gray;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-centre:focus-within {
|
||||||
|
border: 2px solid $orange;
|
||||||
|
}
|
||||||
274
style/bar.scss
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
@use "colors" as *;
|
||||||
|
|
||||||
|
* {
|
||||||
|
all: unset;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
color: $foreground;
|
||||||
|
border: unset;
|
||||||
|
padding: unset;
|
||||||
|
border-radius: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock {
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaces {
|
||||||
|
color: $foreground;
|
||||||
|
background: $background;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-button {
|
||||||
|
padding: 0 9px 0;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-button:hover {
|
||||||
|
background: $background1;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-button.active {
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
border-bottom: 3px solid $orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submap {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submap.active {
|
||||||
|
background: $red;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submap.active:hover {
|
||||||
|
background: $lightred;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: $foreground;
|
||||||
|
background: $background;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title.media {
|
||||||
|
background: $orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title.media:hover {
|
||||||
|
background: $lightorange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title.media.playing {
|
||||||
|
background: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title.media.playing:hover {
|
||||||
|
background: $lightgreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.utilities {
|
||||||
|
color: $foreground;
|
||||||
|
background: $background;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-icon {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-icon.muted {
|
||||||
|
color: $lightblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brightness {
|
||||||
|
color: $foreground;
|
||||||
|
padding: 0 6px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brightness-button {
|
||||||
|
color: $orange;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0 8px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brightness-button:hover {
|
||||||
|
color: $orange;
|
||||||
|
background: $background1;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caffeine {
|
||||||
|
color: $foreground;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin: 3px 0 3px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caffeine:hover {
|
||||||
|
color: $lightred;
|
||||||
|
background: $background1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caffeine.on {
|
||||||
|
background: $red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats:hover {
|
||||||
|
background: $background1;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats .expandable-content {
|
||||||
|
padding: 0 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
color: $orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network {
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network:hover {
|
||||||
|
background: $background1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-label {
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpn-icon {
|
||||||
|
color: $red;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpn-icon:hover {
|
||||||
|
color: $lightred;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpn-icon.on {
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpn-icon.on:hover {
|
||||||
|
color: $lightgreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpn {
|
||||||
|
color: $background;
|
||||||
|
background: $red;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
margin: 3px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpn:hover {
|
||||||
|
background: $lightred;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpn.on {
|
||||||
|
background: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpn.starting {
|
||||||
|
background: $yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink_vpn {
|
||||||
|
to {
|
||||||
|
background-color: $yellow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpn.unknown {
|
||||||
|
background: $red;
|
||||||
|
animation-name: blink_vpn;
|
||||||
|
animation-duration: 0.5s;
|
||||||
|
animation-timing-function: steps(12);
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-direction: alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpn.on:hover {
|
||||||
|
background: $lightgreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery:hover {
|
||||||
|
background: $background1;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery {
|
||||||
|
color: $foreground;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery.charging {
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery.medium {
|
||||||
|
color: $yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery.low {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink_battery {
|
||||||
|
to {
|
||||||
|
background-color: $urgent;
|
||||||
|
color: #282828;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery.critical {
|
||||||
|
color: $red;
|
||||||
|
border-radius: 20px;
|
||||||
|
animation-name: blink_battery;
|
||||||
|
animation-duration: 0.5s;
|
||||||
|
animation-timing-function: steps(12);
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-direction: alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock {
|
||||||
|
color: $foreground;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock:hover {
|
||||||
|
background: $background1;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-button {
|
||||||
|
color: $foreground;
|
||||||
|
padding: 0 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-button:hover {
|
||||||
|
color: $lightred;
|
||||||
|
}
|
||||||
25
style/colors.scss
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
$background: #232320;
|
||||||
|
$background1: #3c3836;
|
||||||
|
|
||||||
|
$foreground: #ebdbb2;
|
||||||
|
|
||||||
|
$orange: #d65d0e;
|
||||||
|
$lightorange: #d77331;
|
||||||
|
$green: #98971a;
|
||||||
|
$lightgreen: #b8bb26;
|
||||||
|
$yellow: #d79921;
|
||||||
|
$lightyellow: #fabd2d;
|
||||||
|
$red: #cc241d;
|
||||||
|
$lightred: #fb4934;
|
||||||
|
$blue: #0db7ed;
|
||||||
|
$lightblue: #83a598;
|
||||||
|
$magenta: #b16286;
|
||||||
|
$lightmagenta: #d3869b;
|
||||||
|
$cyan: #689d6a;
|
||||||
|
$lightcyan: #8ec07c;
|
||||||
|
$gray: #928374;
|
||||||
|
$lightgray: #a89984;
|
||||||
|
|
||||||
|
$warning: $yellow;
|
||||||
|
$error: $red;
|
||||||
|
$urgent: $red;
|
||||||
3
style/style.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@use "widgets";
|
||||||
|
@use "bar";
|
||||||
|
@use "action_centre";
|
||||||