├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── doc ├── config.md ├── rwaybar.toml ├── sample-black.png └── sample-white.png └── src ├── bar.rs ├── data.rs ├── dbus.rs ├── event.rs ├── font.rs ├── icon.rs ├── item.rs ├── main.rs ├── mpris.rs ├── pipewire.rs ├── pulse.rs ├── render.rs ├── state.rs ├── sway.rs ├── tray.rs ├── util.rs ├── wayland.rs └── wlr.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rwaybar" 3 | version = "0.2.0" 4 | authors = ["Daniel De Graaf "] 5 | edition = "2021" 6 | default-run = 'rwaybar' 7 | 8 | [profile.dev] 9 | # 400ms frames are more annoying than a bit more work on compile 10 | opt-level = 1 11 | panic = 'abort' 12 | 13 | [profile.release] 14 | panic = 'abort' 15 | lto = true 16 | 17 | [features] 18 | default = ['dbus'] 19 | dbus = [] 20 | pulse = ['libpulse-binding', 'libpulse-tokio'] 21 | 22 | [dependencies] 23 | # Basic runtime 24 | bytes = "*" 25 | async-once-cell = "0.5" 26 | env_logger = "0.11" 27 | futures-channel = { version = "*" } 28 | futures-util = { version = "*", features = ['channel'] } 29 | json = "*" 30 | libc = "*" 31 | log = "*" 32 | memmap2 = "0.9" 33 | once_cell = "*" 34 | serde = "1" 35 | strfmt = "=0.2.4" 36 | tokio = { version = "1", features = ['rt', 'net', 'signal', 'sync', 'io-util', 'time'] } 37 | toml = "0.8" 38 | xdg = "*" 39 | xml-rs = "*" 40 | 41 | # GUI 42 | png = "0.17" 43 | resvg = { version = "0.40", default-features = false } 44 | smithay-client-toolkit = { version = "0.18.1", default-features = false } 45 | #smithay-client-toolkit = { version = "*", default-features = false, path = "../smithay-client-toolkit" } 46 | tiny-skia = "0.11" 47 | ttf-parser = "*" 48 | wayland-client = { version = "0.31" } 49 | wayland-cursor = { version = "0.31" } 50 | wayland-protocols = { version = "0.31", features = ['unstable', 'staging'] } 51 | wayland-protocols-wlr = { version = "0.2" } 52 | 53 | # Module specific 54 | chrono = { version = "*", default-features = false, features = ['clock'] } 55 | chrono-tz = "*" 56 | evalexpr = "11" 57 | libpulse-binding = { version = "*", features = ['pa_v14'], optional = true } 58 | libpulse-tokio = { version = "0.1", optional = true } 59 | regex = "1.5" 60 | zbus = { version = "4", default-features = false, features = ['tokio'] } 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2021 Daniel De Graaf 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rwaybar 2 | 3 | A taskbar for Wayland written in Rust. Works on any compositor supporting 4 | the layer\_shell protocol (sway, most wlroots window managers, kwin). 5 | 6 | ## Available Modules 7 | 8 | - Clipboard (viewer) 9 | - Clock and calendar 10 | - Custom scripts or dbus API queries 11 | - Disk (filesystem) free 12 | - Icons (including custom images) 13 | - File reader (for showing battery, temperature, load average, etc.) 14 | - MPRIS-compliant media player support (title display, basic control) 15 | - Pipewire and Pulseaudio volume and mute controls 16 | - Sway (window tree, workspaces, binding mode) 17 | - Tray 18 | 19 | See the [configuration documentation](doc/config.md) for details. 20 | 21 | ## Other Features 22 | 23 | - Clicks can execute custom scripts or provide input to existing ones 24 | - Support for showing meters by choosing or fading between multiple images or glyphs. 25 | - Reformatting of values using regular expressions and/or numeric expressions 26 | - Config reload on SIGHUP 27 | 28 | ## Building 29 | 30 | ```bash 31 | cargo build --release 32 | cp doc/rwaybar.toml ~/.config/ 33 | ./target/release/rwaybar 34 | ``` 35 | 36 | You should modify the example config to match your outputs and to configure 37 | where and what you want on your bar. Specify the environment variable 38 | `RUST_LOG=debug` to enable more verbose debugging. 39 | 40 | You can also enable or disable some features using cargo's feature flags. 41 | Currently there are two features: 42 | 43 | - `dbus` - Enable dbus support. Required for MPRIS and tray; enabled by default. 44 | - `pulse` - Enable pulseaudio support. Not enabled by default; requires pulse libraries. 45 | 46 | ## Samples 47 | 48 | ![sample bar](doc/sample-black.png "Bar with black background") 49 | 50 | ![sample bar](doc/sample-white.png "Same bar with white background") 51 | 52 | These two samples are using the same configuration, only the background color 53 | differs. I like a transparent background on my taskbar, but I also configure 54 | my desktop background to be a slideshow. This means that I need the bar to be 55 | readable regardless of the color of the background, which was done in this 56 | example by using text-outline. The tray needs a solid background because some 57 | icons (kdeconnect, steam) aren't otherwise visible on light backgrounds. 58 | 59 | Note: these images were captured on a scaled (HiDPI) output, which is why they 60 | appear double the size defined in the sample bar configuration. 61 | 62 | ## Motivation 63 | 64 | This started out as a 'how does wayland work, anyway?' project. I then decided 65 | that I liked the look of a transparent bar, and started adding features like 66 | text-outline to make the output more readable, and adding modules to display 67 | more data. I later decided to remove the C library dependencies that could be 68 | replaced by rust-native ones. 69 | -------------------------------------------------------------------------------- /doc/config.md: -------------------------------------------------------------------------------- 1 | # Bar definition 2 | 3 | It is possible to define multiple bars (usually you use this to define one per 4 | output). A bar is defined as follows: 5 | 6 | ```toml 7 | [[bar]] 8 | name = "DP-1" 9 | size = 40 10 | side = "top" 11 | left = ["clock", "mode"] 12 | right = "dp-right" 13 | 14 | ``` 15 | 16 | Key | Value | Expanded | Default 17 | ----|-------|----------|-------- 18 | `name` | The output name (connector) for this bar. | No | Display on all outputs matching make, model, and description. 19 | `make` | A regex that must match the make of the monitor | No | Display on all monitors 20 | `model` | A regex that must match the model of the monitor | No | Display on all monitors 21 | `description` | A regex that must match the description of the monitor | No | Display on all monitors 22 | `size` | The size of the bar in pixels | No | `20` 23 | `size-exclusive` | Number of pixels to reserve for the bar | No | (`size`) 24 | `size-clickable` | Number of pixels of the bar that are clickable | No | (`size-exclusive`) 25 | `side` | `top` or `bottom` | No | `bottom` 26 | `layer` | `top`, `bottom`, or `overlay` | No | `top` 27 | `sparse-clicks` | `true` if clicks should only be captured where active | No | `true` 28 | `left` | Block or list of blocks | No | None 29 | `center` | Block or list of blocks | No | None 30 | `right` | Block or list of blocks | No | None 31 | `tooltips` | Formatting for tooltips | No | `{ bg = "black", fg = "white", padding = "2" }` 32 | 33 | You can view the name/make/model/description for your monitors by running 34 | `RUST_LOG=info rwaybar`; they are also displayed by default if the 35 | configuration does not produce any matching bars. 36 | 37 | Note: the bar configuration may also include [formatting rules](#formatting) 38 | and other arbitrary text values accessible in [text expansions](#text-expansion). 39 | 40 | If you don't like dedicating an entire edge of the screen to the bar, you can 41 | set `size-exclusive` to 0 to have the bar display over other windows; in 42 | combination with `sparse-clicks`, transparent backgrounds, and careful 43 | positioning of any visible or clickable items, this can avoid wasting screen 44 | space for the bar but still have some information be visible, drawn over other 45 | windows or unused parts of the desktop surface (for example, the top-right of a 46 | fullscreen window title-bar). 47 | 48 | # Common attributes 49 | 50 | With a few exceptions where it is inferred, every block in the configuration 51 | requires a `type` field declaring which module provides the contents of the 52 | block. 53 | 54 | ## Text Expansion 55 | 56 | Most values accept text expansion using `{block-name.key:format}` similar to python 57 | `f""` or rust `format!` strings. The `:format` part is optional; if present, 58 | it allows formatting the string (adding padding, restricting width, etc). The 59 | `block-name` part is mandatory and defines which block (as defined in your 60 | configuration) to consult for the item. The `.key` part is optional and allows 61 | modules to provide multiple values; see the module-specific documentation for 62 | details. 63 | 64 | A text expansion using `{=formula:format}` is evaluated as a mathematical 65 | expression in the same way as the [eval](#eval) block. The values of any named 66 | block will be made available to the expression (it is not possible to specify a 67 | key or use a block with a hyphen, but see the `get` function). Note that it is 68 | not possible to use a `:` character in the expression, regardless of escaping 69 | or quoting; use an eval block if this is a problem. 70 | 71 | ## Formatting 72 | 73 | Any block may contain one or more of the following keys, which influence the 74 | display of the item. While the names were chosen to be similar to CSS when 75 | possible, not all features of the corresponding CSS property are present. 76 | 77 | All formatting values are subject to [text expansion](#text-expansion). 78 | 79 | Key | Value | Details 80 | ----|-------|--------- 81 | `align` | `north`, `south`, `east`, `west`, `center` | Simple alignment of the item. See the `halign` and `valign` properties for more control. 82 | `bg` | `red` or `#ff0000` | Background color (without transparency) 83 | `bg-alpha` | 0.2 (20% opaque) | Background opacity 84 | `border` | `1 2 3 4` (pixels) | Border width for the top, right, bottom, and left sides. Like CSS, you can omit some of the values if they are the same. 85 | `border-alpha` | 0.7 (70% opaque) | Border opacity 86 | `border-color` | `red` or `#ff0000` | Border color (without transparency) 87 | `fg` | `red` or `#ff0000` | Foreground color (without transparency) 88 | `fg-alpha` | 0.7 (70% opaque) | Foreground opacity 89 | `font` | A font name and size | 90 | `halign` | `20%` | Horizontal alignment (only used when min-width is present) 91 | `margin` | `1 2 3 4` (pixels) | Margin width for the top, right, bottom, and left sides. Like CSS, you can omit some of the values if they are the same. 92 | `max-width` | `30%` or `40` (pixels) | Minimum width for this block. If the contents are larger, they will be cropped. 93 | `min-width` | `30%` or `40` (pixels) | Minimum width for this block. If the contents are smaller, blank space is added and the contents are positioned according to `halign` 94 | `padding` | `1 2 3 4` (pixels) | Padding width for the top, right, bottom, and left sides. Like CSS, you can omit some of the values if they are the same. 95 | `text-outline` | `red` or `#ff0000` | Color for text outline 96 | `text-outline-alpha` | `0.5` | Opacity of the outline 97 | `text-outline-width` | `2.0` | Width of the outline (in pixels) 98 | `valign` | `20%` | Vertical alignment (of text) 99 | 100 | ## Actions 101 | 102 | Any block may contain one of the following keys that define actions to take 103 | when the block is clicked. 104 | 105 | Key | Details 106 | ----|-------- 107 | `on-click` | Left button or tap 108 | `on-click-left` | Left button 109 | `on-click-right` | Right button 110 | `on-click-middle` | 111 | `on-click-backward` | May also be known as "side" 112 | `on-click-forward` | May also be known as "extra" 113 | `on-tap` | For touchscreens 114 | `on-scroll-up` | 115 | `on-scroll-down` | 116 | `on-vscroll` | A combination of up and down 117 | `on-scroll-left` | 118 | `on-scroll-right` | 119 | `on-hscroll` | A combination of left and right 120 | `on-scroll` | A scroll in any of the 4 directions 121 | 122 | Actions can either be a direct program execution, for example: 123 | 124 | ```toml 125 | on-click = { exec = "firefox" } 126 | ``` 127 | 128 | Or it can be used to write a value to an existing block, for modules that support this: 129 | 130 | ```toml 131 | on-click = { send = "mpris-block", msg = "PlayPause" } 132 | ``` 133 | 134 | Either `msg` or `format` are valid; both are text-expanded before sending to the module. 135 | 136 | If the bar-level setting `sparse-clicks` is true, then any element without a 137 | tooltip or an on-click handler will be transparent to clicks and touches. 138 | 139 | ## Text Module 140 | 141 | Any module that does not declare otherwise is displayed as text, controlled by the following keys: 142 | 143 | Key | Expanded | Value | Details 144 | ----|----------|-------|--------- 145 | `markup` | No | true/false | True if the value contains HTML-style markup 146 | `oneline` | No | true/false | True if the value should have newlines stripped 147 | 148 | The actual text displayed is `{`modulename`.text}` with a tooltip of `{`modulename`.tooltip}`. 149 | 150 | # Fonts 151 | 152 | The fonts used to render text must currently be defined by declaring a name and 153 | filename in the `[fonts]` section. You can find the font filenames used for 154 | specific named fonts by using the following command: 155 | 156 | `fc-list -f '%{family}: %{file}\n'|sort` 157 | 158 | If a given font does not contain glyphs for a given character, other fonts are 159 | tried in the order they are listed. This configuration may be used to select 160 | particular fonts for emojis or other special characters. 161 | 162 | # Modules 163 | 164 | ## calendar 165 | 166 | The current month's calendar. This always shows 6 weeks, so some days of the 167 | prior and next months are also visible. 168 | 169 | The default formatting values assume you have specified `markup = true` and are using a monospace font. 170 | 171 | Key | Expanded | Default | Details 172 | ----|----------|---------|-------- 173 | `timezone` | Yes | | Time zone to use for calendar (blank uses the system local time zone) 174 | `start` | No | Sunday | This can be set to "Monday" to start weeks on Monday. 175 | `day-format` | No | ` %e` | Format for days of the current month that are not today. 176 | `today-format` | No | ` %e` | Format for the current day. 177 | `other-format` | No | ` %e` | Format for days of the prior and next months. 178 | `before` | No | - | If set to an integer, display that many weeks before today instead of this month. 179 | `after` | No | before | If `before` is set, this controls how many weeks after today to display. 180 | 181 | ## clipboard 182 | Key | Expanded | Default | Details 183 | ----|----------|---------|-------- 184 | `seat` | No | unset | Wayland seat name to watch; if unset, watches all seats 185 | `selection` | No | false | Use the "primary selection" instead of the clipboard 186 | `mime_types` | No | * | A list of preferred MIME types for clipboard contents 187 | 188 | If `mime_types` is unset, it defaults to `["text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT"]` 189 | 190 | ## clock 191 | 192 | Key | Expanded | Default | Details 193 | ----|----------|---------|-------- 194 | `format` | Yes | `%H:%M` | Time format using the strftime inspired date and time formatting [syntax](https://docs.rs/chrono/latest/chrono/format/strftime/index.html) 195 | `timezone` | Yes | | Time zone to display (blank uses the system local time zone) 196 | 197 | ## dbus 198 | 199 | Invokes an arbitrary dbus method to get data 200 | 201 | #### Configuration 202 | 203 | Key | Expanded | Default | Details 204 | ----|----------|---------|-------- 205 | `owner` | No | -- | A dbus destination for the method call 206 | `path` | No | -- | A dbus object path on which to invoke the method or inspect the property 207 | `method` | No | -- | A dbus method (interface`.`member) to invoke (exclusive with property) 208 | `property` | No | -- | A dbus property (interface`.`member) to inspect (exclusive with method) 209 | `args` | No | [] | Arguments to the method. Only strings and floats are currently supported. 210 | `poll` | No | 0 | Number of seconds to wait between calls, or 0 to disable polling 211 | `watch-method` | No | -- | A dbus signal (interface`.`member) to watch for updates 212 | `watch-path` | No | -- | A dbus object path to which the signal must be attached 213 | 214 | If `watch-method` is set, any broadcast of the named signal will cause the 215 | method to be re-invoked (or the property queried) to refresh the result. 216 | Otherwise, the method is only called according to the period defined by poll. 217 | 218 | #### Available Keys 219 | 220 | The first return value of the method (or value of the property) is returned if a non-numeric key is used. 221 | 222 | The key may be a zero-index numeric path separated by `.` to address the list 223 | of return values and the members of returned structs. 224 | 225 | ## disk 226 | 227 | #### Configuration 228 | 229 | Key | Expanded | Default | Details 230 | ----|----------|---------|-------- 231 | `path` | No | -- | Path to the disk 232 | `poll` | No | 60 | Number of seconds to wait between reads 233 | 234 | #### Available Keys 235 | 236 | Key | Value 237 | ----|------- 238 | `size` | Size in bytes 239 | `free` | Free space in bytes (including reserved space) 240 | `avail` | Available space in bytes (not including reserved space) 241 | `percent-used` | The percentage of disk space that is used 242 | 243 | You may suffix any of the byte sizes with `mb`, `gb`, or `tb` to get the sizes 244 | as numbers using the SI definitions, or `mib`, `gib`, `tib` to get the 245 | power-of-two versions. You probably want to use a format like 246 | `{disk.size-gib:.1}` to avoid excessive precision in the output. 247 | 248 | ## eval 249 | 250 | Key | Expanded | Default | Details 251 | ----|----------|---------|-------- 252 | `expr` | No | -- | Expression to evaluate 253 | \* | Yes | -- | Variables usable in the expression 254 | 255 | Evaluates the given expression. Basic math and logic operators are supported, 256 | but not variable assignment, conditionals, looping, or recursion. Variables 257 | referenced in this expression refer to the expanded value of other keys in this 258 | block. See [https://docs.rs/evalexpr/#builtin-functions] for a list of 259 | available functions, in addition to: 260 | 261 | Function | Argument | Description 262 | ---------|----------|------------- 263 | `float` | Any | Convert the value to a number 264 | `int` | Any | Convert the value to an integer 265 | `get` | String | Read a value from a named block, for example `get("disk.percent-used")` 266 | 267 | ## exec-json 268 | 269 | Key | Expanded | Default | Details 270 | ----|----------|---------|-------- 271 | `command` | No | -- | Shell command to execute 272 | 273 | The output of the shell command should be a stream of JSON values, one per 274 | line. The text expansion of this module will consult the most recent command 275 | output for a matching key and return its value. 276 | 277 | The command will not be restarted if it exits; use a wrapper script that calls 278 | it in a loop if you want to do this. 279 | 280 | ## fade 281 | 282 | This module allows combining two items to show a fraction of each. For 283 | example, if the two items are "empty battery" and "full battery" icons, this 284 | would show a part-full battery icon whose fullness is determined by the value. 285 | 286 | It is possible to use more than two items - for example, a temperature meter 287 | might have (empty, full, red), and depending on the temperature, might display 288 | as half-full or half-full/half-red. 289 | 290 | Because it is likely that vaules need to be scaled or offset for this module, 291 | it is possible to use `expr` as in the `eval` block type instead of setting the 292 | `value` key directly. 293 | 294 | If the two items being displayed are different sizes, the size of the first of 295 | the two items is used to crop the second. 296 | 297 | Key | Expanded | Default | Details 298 | ----|----------|---------|-------- 299 | `items` | N/A | | A list of items 300 | `dir` | No | `left` | Which direction does the dividing line move when increasing the value? Valid values are `left`, `right`, `up`, `down`. 301 | `value` | Yes | | A fraction between 0 and `N - 1` determining which items to display 302 | `expr` | No | | An expression (as in the expr module) evaluating to the value (used if `value` is not set) 303 | `tooltip` | Yes | "" | The tooltip to display when hovering over the text 304 | 305 | ## focus-list 306 | 307 | Key | Expanded | Default | Details 308 | ----|----------|---------|-------- 309 | `source` | No | -- | A module name that exposes a list of values 310 | `item` | N/A | | A block (or block name) to display for each item in the list 311 | `focused-item` | N/A | Same as item | A block to display for items marked as "focused" in the list 312 | 313 | When inside a focus-list block, the `item` block refers to the current item (so 314 | `{item.title}` would refer to the title key). 315 | 316 | ## font-test 317 | 318 | This expands to a table of glyphs in the current font. Best used as a tooltip, as seen in the example config. 319 | 320 | Before each character is the HTML entity that will produce the character when 321 | passed in a block with `markup = true`. This is wlll be something like `#84` 322 | (written fully as `"Test"` for displaying `Test`). If the output is 323 | `@1234`, then there is no unicode character that results in this glyph being 324 | output; you can use `&@1234;` to display it anyway. This is generally due to 325 | the glyph being used to render some multi-code-point entity. 326 | 327 | ## formatted 328 | 329 | *Note*: The `type = formatted` key is optional for this module as long as you 330 | specify the format. 331 | 332 | Key | Expanded | Default | Details 333 | ----|----------|---------|-------- 334 | `format` | Yes | -- | The string to display 335 | `tooltip` | Yes | "" | The tooltip to display when hovering over the text 336 | 337 | ## group 338 | 339 | Key | Expanded | Value | Details 340 | ----|----------|-------|-------- 341 | `condition` | Yes | empty or non-empty | If this value is set but empty, the group will not be displayed 342 | `spacing` | Yes | number of pixels | Spacing between each item in the group. May be negative. 343 | 344 | ## icon 345 | 346 | Key | Expanded | Default | Details 347 | ----|----------|---------|-------- 348 | `name` | Yes | -- | The name of an icon to display 349 | `fallback` | Yes | -- | The string to display if no icon is found 350 | `tooltip` | Yes | "" | The tooltip to display when hovering over the icon 351 | 352 | Icon names can either be a full path to a PNG or SVG image, or a name stem that 353 | is searched for in the XDG `pixmap` and `icons` directories (usually under 354 | `~/.local/share/` and `/usr/share/`). They will be scaled to the height of the 355 | available space. 356 | 357 | ## list 358 | 359 | An item whose value can be selected from a list by actions. This can be used 360 | to allow fast switching of things like the timezone for a clock. 361 | 362 | Key | Expanded | Default | Details 363 | ----|----------|---------|-------- 364 | `default` | No | 1 | The initial item selected (1 = first). 365 | `values` | No | -- | A list of values to select from 366 | `wrap` | No | true | Do adjustments on the list wrap around? 367 | 368 | #### Actions 369 | 370 | An action should be applied to the item that actually displays the value being 371 | adjusted by this item, referring to the list object by name. The message can 372 | be `+`, `-`, `+N`, `-N`, or `=N`. 373 | 374 | For example 375 | 376 | ```toml 377 | on-click = { send = "name-of-the-list-block", msg = "+1" } 378 | ``` 379 | 380 | Setting `wrap = false` is best paired with a scroll action: 381 | 382 | ```toml 383 | on-scroll-up = { send = "name-of-the-list-block", msg = "+" } 384 | on-scroll-down = { send = "name-of-the-list-block", msg = "-" } 385 | ``` 386 | 387 | ## meter 388 | 389 | Key | Expanded | Default | Details 390 | ----|----------|---------|-------- 391 | `src` | Yes | -- | Source value (must expand to a floating-point number for a working meter) 392 | `min` | Yes | 0 | Minimum value for the "valid" range of the meter 393 | `max` | Yes | 100 | Maximum value for the "valid" range of the meter 394 | `values` | Yes | -- | List of format values, such as `["", "", "", "", "", ""]` 395 | `below` | Yes | (first value) | Format to use when the value is below `min` 396 | `above` | Yes | (last value) | Format to use when the value is above `max` 397 | 398 | ## mpris 399 | 400 | #### Configuration 401 | 402 | Key | Expanded | Default | Details 403 | ----|----------|---------|-------- 404 | `name` | No | "" | Name of the default player for this item; if empty, the first "playing" player will be used. 405 | 406 | #### Values 407 | 408 | All string (and string list) values defined by the [mpris metadata spec](http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata) 409 | are available, in addition to `length` which is the track length in seconds, 410 | and `player.name` which is the mpris endpoint name (which may be something like 411 | `firefox.instance1234567`). 412 | 413 | #### Actions 414 | 415 | For all actions, the target of the action is either the player specified in the block or the first "playing" player. 416 | 417 | `Next` | `Previous` | `Pause` | `PlayPause` | `Stop` | `Play` | `Raise` | `Quit` 418 | 419 | ## pipewire 420 | 421 | Note: while pipewire supports more than audio, most applications that stream 422 | video still open the camera device directly, bypassing pipewire. 423 | 424 | #### When used as a normal item 425 | 426 | Key | Expanded | Default | Details 427 | ----|----------|---------|-------- 428 | `target` | No | `""` | A device name (`device.name` in `pw-dump`, or the `name` below). 429 | 430 | #### Values 431 | 432 | Key | Sample |Details 433 | ----|--------|------- 434 | `active` | `1` | boolean; `1` means something is playing or recording 435 | `device` | `Built-in Audio` | A human-readable description of the device 436 | `mute` | `0` | boolean; `1` means muted 437 | `name` | `alsa_card.pci-0000_00_1f.3` | The name of the device and route (usable in `target` declarations) 438 | `route` | `HDMI 2` | A human-readable description of the route (part of the device) 439 | `text` | `80%` | Textual representation of the volume 440 | `tooltip` | | A verbose description of the volume, route, and a list of clients and volumes. 441 | `volume` | `80.41828` | Volume as a decimal percentage 442 | 443 | #### When used as a focus-list source 444 | 445 | The `target` key must be one of the following values for use as focus-list: 446 | 447 | Value | Listed items 448 | ------|------------- 449 | `inputs` or `sources` | Source devices (microphones) 450 | `outputs` or `sinks` | Sink devices (speakers) 451 | `all` | All devices 452 | 453 | Items of the focus-list have the same values as listed above. 454 | 455 | ## pulse 456 | 457 | This requires the `pulse` feature to be enabled. 458 | 459 | #### When used as a normal item 460 | 461 | Key | Expanded | Default | Details 462 | ----|----------|---------|-------- 463 | `target` | No | `"sink"` | Either `sink:` or `source:` followed by the name of the particular sink. Names can be obtained from `pactl list` and look like `alsa_output.pci-0000_00_1f.3.analog-stereo`. 464 | 465 | #### Values 466 | 467 | Key | Details 468 | ----|-------- 469 | `mute` | `0` or `1` where `1` means muted 470 | `volume` | Textual representation of the volume like "`80%`" 471 | `tooltip` | A verbose description of the volume, port, and a list of clients and volumes that are connected to the port 472 | `type` | The type of device connected, for example `Speaker`, `HDMI`, `Line`, `Phone`, `Mic`, ... 473 | 474 | #### When used as a focus-list source 475 | 476 | The `target` key must be one of the following values for use as focus-list: 477 | 478 | Value | Listed items 479 | ------|------------- 480 | `sources` | Sources (microphones) but not monitors 481 | `sinks` | All sinks (speakers) 482 | `monitors` | Monitor sources (for recording sound your system makes) 483 | `all-sources` | All sources including monitors 484 | `all` | All sources, sinks, and monitors 485 | 486 | ## regex 487 | 488 | Key | Expanded | Default | Details 489 | ----|----------|---------|-------- 490 | `text` | Yes | -- | The text to run the regular expression against 491 | `regex` | No | -- | The regular expression ([syntax](https://docs.rs/regex/#syntax) details) 492 | `replace` | No\* | -- | A replacement string for all matches of the regular expression. `$1`, `${1}`, or `$name` refer to capture groups. 493 | 494 | This block's value is either the replaced string (when called with a blank key) or the group identified by the key. 495 | 496 | ## read-file 497 | 498 | Key | Expanded | Default | Details 499 | ----|----------|---------|-------- 500 | `file` | No | -- | File name to read 501 | `path` | Wildcards | -- | File name to read, with wildcard `*` expansion 502 | `poll` | No | 60 | Number of seconds to wait between reads 503 | 504 | Note: this is intended for reading files like `/proc/loadavg` where there is no mechanism to watch for changes to the file. 505 | 506 | ## sway-mode 507 | 508 | Expands to the current keybinding mode in sway 509 | 510 | ## sway-tree 511 | 512 | Key | Type | Default | Details 513 | ----|------|---------|-------- 514 | `pre-workspace` | Block | -- | Block shown before displaying the contents of a workspace. `{item.name}` and `{item.output}` are available. 515 | `pre-node` | Block | -- | Block shown before displaying a container. See below for item contents. 516 | `window` | Block | -- | Block shown for every window in a container. See below for item contents. 517 | `post-node` | Block | -- | Block shown after displaying a container. See below for item contents. 518 | `pre-floats` | Block | -- | Block shown bewtween the tiled and floating containers on a workspace if there are floating windows. 519 | `pre-float` | Block | -- | Block shown before displaying a floating container. 520 | `post-float` | Block | -- | Block shown after displaying a floating container. 521 | `post-workspace` | Block | -- | Block shown after displaying the contents of a workspace. `{item.name}` and `{item.output}` are available. 522 | `output` | String | -- | If non-empty, only show workspaces on the given output. Set to `{bar.name}` for the current output (this works even if you didn't set a name in `[[bar]]`) 523 | `workspace` | String | -- | If non-empty, only show the workspace with the given name. This could be used to restrict to the focused workspace or to nest in a focus-list of workspaces. 524 | 525 | Within a node (either a container or a window), the following item keys are available: 526 | 527 | Key | Value | Details 528 | ----|-------|-------- 529 | `id` | `23` | The unique ID for the container (`con_id` in sway criteria) 530 | `marks` | "1" | The list of marks on the container, if any 531 | `focus` | `0` or `1` | `1` if the window has focus. 532 | `appid` | `firefox` | The app\_id or Class (for Xwayland) of the window (windows only) 533 | `icon` | `firefox` | The icon name associated with the window (if known) 534 | `title` | | The window title (windows only) 535 | `layout` | `H` | The layout of the container. Will be one of `H`, `V`, `T`, or `S`. 536 | 537 | Actions on a node directed at the current item may specify a sway command, 538 | which will be prefixed with a `[con_id]` criteria and executed. For example: 539 | 540 | ```toml 541 | [tree-block] 542 | type = 'sway-tree' 543 | 544 | [tree-block.pre-node] 545 | format = "{item.layout}[" 546 | 547 | [tree-block.window] 548 | type = 'icon' 549 | name = '{item.icon}' 550 | fallback = '({item.appid})' 551 | tooltip = '{item.title}' 552 | on-click = { send = "item", format = "focus" } 553 | on-click-backward = { send = "item", format = "kill" } 554 | on-scroll-right = { send = "item", format = "move right" } 555 | on-scroll-left = { send = "item", format = "move left" } 556 | 557 | [tree-block.post-node] 558 | format = "]" 559 | ``` 560 | 561 | ## sway-workspace 562 | 563 | The currently selected workspace 564 | 565 | This module is valid as a target for format-list; when used there, it shows all available workspaces. 566 | 567 | Key | Expanded | Default | Details 568 | ----|----------|---------|-------- 569 | `output` | Yes | -- | If non-empty, only show workspaces on the given output. Set to `{bar.name}` for the current output (this works even if you didn't set a name in `[[bar]]`) 570 | 571 | ## switch 572 | 573 | Key | Expanded | Default | Details 574 | ----|----------|---------|-------- 575 | `format` | Yes | -- | Value to match against the possible cases 576 | `default` | Yes | "" | Value to expand if no case matches 577 | `cases` | Yes\* | -- | Table of strings 578 | 579 | The switch block first expands the format value, then matches the resulting 580 | string against the table of keys listed in `cases`. If a match is found, the 581 | resulting value is then expanded and used as the result of the module. 582 | 583 | ```toml 584 | [mic-s] 585 | type = 'switch' 586 | format = '{mic-r.mute}' 587 | default = "" 588 | cases = { 0 = "", 1 = "" } 589 | ``` 590 | 591 | If you have more cases, you might prefer the alternate syntax for tables: 592 | 593 | ```toml 594 | [mic-s] 595 | type = 'switch' 596 | format = '{mic-r.mute}' 597 | default = "" 598 | 599 | [mic-s.cases] 600 | 0 = "" 601 | 1 = "" 602 | ``` 603 | 604 | ## thermal 605 | 606 | Key | Expanded | Default | Details 607 | ----|----------|---------|-------- 608 | `name` | No | -- | Name of the sensor 609 | `file` | No | -- | File name for the sensor, such as `/sys/class/hwmon/hwmon3/temp9_input` 610 | `path` | Wildcards | -- | File name for the sensor, such as `/sys/block/nvme0n1/device/hwmon*/temp1_input` 611 | `poll` | No | 60 | Number of seconds to wait between reads 612 | 613 | This returns the temperature as reported by the kernel (in degrees celsius). 614 | 615 | Only one of `name`, `file`, or `path` needs to be specified. 616 | 617 | The `path` entry will have an contained `*` characters expanded similar to 618 | shell wildcard expansion. This allows using paths that do not change depending 619 | on the kernel version and/or the order the kernel discovers devices. 620 | 621 | See the `meter` block to convert the number to a visual representation. 622 | 623 | ## tray 624 | 625 | The tray contains up to three sub-blocks (like focus-list). The `item` block 626 | is used by default, and if not present, defaults to the icon. Icons that have 627 | marked themselves as "NeedsAttention" use the `urgent` block if present but are 628 | otherwise shown as normal. Icons that are marked as "Passive" are hidden by 629 | default, but are displayed if a `passive` block is present. 630 | 631 | #### Item values 632 | 633 | Key | Value 634 | ----|---------- 635 | `icon` | The path or name of the icon, suitable for passing to an `icon` block as name 636 | `id` | The ID of this icon, which is suitable to identify specific icons in a `switch` block 637 | `title` | The title of the item, shown in the menu/tooltip 638 | `status` | The status string for this item (Passive, Active, or NeedsAttention) 639 | `tooltip` | The tooltip set by this item, if any 640 | 641 | ## value 642 | 643 | *Note*: The `type = value` key is optional for this module as long as you 644 | specify the value. 645 | 646 | Key | Expanded | Default | Details 647 | ----|----------|---------|-------- 648 | `value` | No | "" | A string value that can be changed by actions 649 | 650 | The value module accepts value sent to it by [actions](#actions), which you can 651 | use to have some blocks control the contents of others. 652 | -------------------------------------------------------------------------------- /doc/rwaybar.toml: -------------------------------------------------------------------------------- 1 | # Bars are defined as an array, so you can have more than one 2 | [[bar]] 3 | name = "DP-3" 4 | left = "dpl" 5 | right = "dpr" 6 | size = 40 7 | font = "Roboto 18" 8 | align = "south" 9 | side = "top" 10 | the-clock-font = "Liberation Sans 26" 11 | 12 | [[bar]] 13 | name = "HDMI-A-1" 14 | left = "tvl" 15 | center = "clock" 16 | right = "tvr" 17 | size = 20 18 | the-clock-font = "Liberation Sans 20" 19 | 20 | [[bar]] 21 | name = "eDP-1" 22 | left = "tvl" 23 | center = "clock" 24 | right = "tvr" 25 | size = 20 26 | the-clock-font = "Liberation Sans 20" 27 | 28 | [[bar]] 29 | name = "WL-1" 30 | left = "tvl" 31 | center = "clock" 32 | right = "tvr" 33 | size = 30 34 | fg = "white" 35 | bg = "black" 36 | bg-alpha = 0.0 37 | text-outline = "black" 38 | text-outline-alpha = 0.8 39 | the-clock-font = "Liberation Sans 20" 40 | tooltips = { bg = "#000c", border = "1", border-color = "#00f4" } 41 | 42 | [[bar]] 43 | name = "WL-2" 44 | side = "top" 45 | center = "clock" 46 | size = 28 47 | size-exclusive = 2 48 | fg = "white" 49 | bg = "black" 50 | bg-alpha = 0.0 51 | text-outline = "black" 52 | text-outline-alpha = 0.8 53 | the-clock-font = "Liberation Sans 20" 54 | 55 | [fonts] 56 | # Named fonts are tried first, then all other fonts are searched for missing glyphs 57 | # `fc-list` can be used to find fonts on your system. 58 | "Liberation Sans" = "/usr/share/fonts/liberation-sans/LiberationSans-Regular.ttf" 59 | mono = "/usr/share/fonts/liberation-mono/LiberationMono-Regular.ttf" 60 | symbola = "/usr/share/fonts/gdouros-symbola/Symbola.ttf" 61 | # Note: this font provides images for emojis, so it disregards the font color. 62 | emoji = "/usr/share/fonts/google-noto-color-emoji-fonts/NotoColorEmoji.ttf" 63 | # This font provides the '' thermometer symbols used in the example 64 | # temperature meter, in addition to some non-colored emojis. 65 | fontawesome = "/usr/share/fonts/fontawesome/FontAwesome.otf" 66 | 67 | # Everything else is defined in its own section and addressed by name elsewhere. 68 | [dpl] 69 | type = "group" 70 | fg = "white" 71 | bg = "black" 72 | bg-alpha = 0.3 73 | items = ["clock", "mode", "workspaces", "workspaces-repr"] 74 | 75 | [time_fmt] 76 | # type='value' is implied if the only key is 'value' 77 | # Value is not format-expanded, but its contents can be set by actions (see clock below) 78 | value = "%H:%M" 79 | 80 | [time] 81 | type = "clock" 82 | format = "{time_fmt}" 83 | 84 | [time-et] 85 | type = "clock" 86 | format = "%H:%M" 87 | timezone = "America/New_York" 88 | 89 | [time-ct] 90 | type = "clock" 91 | format = "%H:%M" 92 | timezone = "America/Chicago" 93 | 94 | [time-pt] 95 | type = "clock" 96 | format = "%H:%M" 97 | timezone = "America/Los_Angeles" 98 | 99 | [time-utc] 100 | type = "clock" 101 | format = "%H:%M:%S" 102 | timezone = "UTC" 103 | 104 | [date] 105 | type = "clock" 106 | format = "%A %Y-%m-%d" 107 | 108 | [clock] 109 | type = "text" 110 | format = "{time}" 111 | # The expression "{bar.some-value}" reads configuration items from the 112 | # currently rendering bar, which is useful if you want to have bars of 113 | # different sizes without duplicating all your items just to change one value 114 | font = "{bar.the-clock-font}" 115 | margin = "0 10" 116 | on-click = { "exec" = "gnome-calendar" } 117 | on-click-middle = { "write" = "time_fmt", "format" = "%H:%M" } 118 | on-click-right = { "write" = "time_fmt", "format" = "%H:%M:%S" } 119 | 120 | # A tooltip can either be a string or an item 121 | #tooltip = "{date}\n{time-et} Eastern\n{time-ct} Central\n{time-pt} Pacific\n{time-utc} UTC" 122 | 123 | [clock.tooltip] 124 | type = 'group' 125 | spacing = 4 126 | 127 | # The items list in a group can be expanded inline instead of referencing items by name 128 | [[clock.tooltip.items]] 129 | format ="{date}\n{time-et} Eastern\n{time-ct} Central\n{time-pt} Pacific\n{time-utc} UTC" 130 | 131 | [[clock.tooltip.items]] 132 | type = 'calendar' 133 | font = "mono 13" 134 | markup = true 135 | 136 | [sway-mode] 137 | type = "sway-mode" 138 | padding = "3" 139 | bg = "red" 140 | 141 | [mode] 142 | type = 'group' 143 | # The entire group will be hidden if condition expands to an empty string. 144 | condition = "{sway-mode}" 145 | items = ["sway-mode"] 146 | 147 | [sway-workspace] 148 | type = "sway-workspace" 149 | 150 | [workspaces] 151 | type = "focus-list" 152 | source = "sway-workspace" 153 | 154 | # Instead of writing "workspaces.item = 'an_item'", we can use a sub-key like this: 155 | # (note you can't address such sub-keys from other items) 156 | [workspaces.item] 157 | format = " {item} " 158 | margin = "0 1 0 1" 159 | padding = "0 0 5 0" 160 | border = "3 0 0 0" 161 | border-alpha = 0 162 | on-click = { "send" = "sway-workspace.switch", "format" = "{item}" } 163 | 164 | [workspaces.focused-item] 165 | format = " {item} " 166 | margin = "0 1 0 1" 167 | padding = "0 0 5 0" 168 | border = "3 0 0 0" 169 | bg = "#197d9b" 170 | bg-alpha = 0.8 171 | 172 | [workspaces-repr] 173 | type = "sway-tree" 174 | font = "Liberation Sans 10" 175 | align = "center" 176 | margin = "2 0 6 0" 177 | markup = true 178 | 179 | [workspaces-repr.pre-node] 180 | format = "{item.layout}[" 181 | 182 | [[workspaces-repr.window]] 183 | format = '{item.marks}' 184 | 185 | [[workspaces-repr.window]] 186 | type = 'icon' 187 | name = '{item-icon-path}' 188 | fallback = '({item.appid})' 189 | tooltip = '{item.title}' 190 | border = "0 0 2 0" 191 | padding = "2 0" 192 | border-color = "#69cbad" 193 | border-alpha = '{item.focus}' 194 | on-click = { send = "item", format = "focus" } 195 | on-click-middle = { send = "item", format = "kill" } 196 | on-scroll-right = { send = "item", format = "move right" } 197 | on-scroll-left = { send = "item", format = "move left" } 198 | 199 | [workspaces-repr.post-node] 200 | format = "]" 201 | 202 | [workspaces-repr.pre-float] 203 | format = " + " 204 | 205 | [item-icon-path] 206 | type = 'switch' 207 | format = '{item.icon}' 208 | default = '{item.icon}' 209 | 210 | [item-icon-path.cases] 211 | Gitk = '/usr/share/icons/breeze-dark/apps/48/git-gui.svg' 212 | 213 | 214 | # The right side of the bar on DP-1 215 | [dpr] 216 | type = "group" 217 | fg = "white" 218 | bg = "black" 219 | bg-alpha = 0.3 220 | spacing = 15 221 | padding = "0 0 4 0" 222 | margin = "0 0 0 -10" 223 | items = ["playing", "players", "pw-spk", "pw-mic", "tray", "weather", "hdd", "bat", "cpu"] 224 | 225 | [music] 226 | type = "mpris" 227 | # The mpris module can either try to auto-detect the right player to control or 228 | # it can take a name parameter, for example: 229 | # name = 'audacious' 230 | 231 | # It exposes the properties that your media player sets; try the values listed 232 | # at http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata or look 233 | # at what shows up in 'dbus-monitor' after a track change. 234 | 235 | # The mpris module also supports actions (shown below) 236 | 237 | [playing] 238 | format = "{music.title}" 239 | # You can also interface with a specific media player here if you didn't set 240 | # name above: format = "{music.audacious.title}" / send = "music.audacious" 241 | font = "Liberation Sans 12" 242 | align = "center" 243 | on-click = { "send" = "music", "format" = "Next" } 244 | on-click-right = { "send" = "music", "format" = "Previous" } 245 | on-click-middle = { "send" = "music", "format" = "PlayPause" } 246 | on-click-backward = { "send" = "music", "format" = "Raise" } 247 | 248 | [players] 249 | type = "focus-list" 250 | source = 'music' 251 | # It is also valid to declare the source inline, like this: 252 | #source = { type = "mpris" } 253 | # Or like this: 254 | #[players.source] 255 | #type = 'mpris' 256 | 257 | [players.focused-item] 258 | format = "" 259 | # Depending on your system fonts, ⏸ and ⏵ might look better 260 | tooltip = "{item.player.name}: {item.title}" 261 | margin = "0 4 4 4" 262 | on-click = { "send" = "item", "format" = "Pause" } 263 | 264 | [players.item] 265 | format = "" 266 | tooltip = "{item.player.name}: {item.title}" 267 | margin = "0 4 4 4" 268 | on-click = { "send" = "item", "format" = "Play" } 269 | 270 | [pw-spk] 271 | type = 'focus-list' 272 | source = { type = 'pipewire', target = 'sinks' } 273 | on-click-middle = { send = "item.mute", format = "toggle" } 274 | on-scroll-up = { send = "item.volume", format = '+5%' } 275 | on-scroll-down = { send = "item.volume", format = '-5%' } 276 | 277 | # The default text of a pipewire item is its volume (including a % sign). 278 | [pw-spk.item] 279 | format = '{item}' 280 | tooltip = '{item.tooltip}' 281 | on-click-middle = { send = "item.mute", format = "toggle" } 282 | on-scroll-up = { send = "item.volume", format = '+5%' } 283 | on-scroll-down = { send = "item.volume", format = '-5%' } 284 | 285 | # This shows one icon for each microphone-like device 286 | [pw-mic] 287 | type = 'focus-list' 288 | source = { type = 'pipewire', target = 'sources' } 289 | 290 | [pw-mic.item] 291 | format = '{mic-s}' 292 | tooltip = '{item.tooltip}' 293 | markup = true 294 | on-click-middle = { send = "item.mute", format = "toggle" } 295 | on-scroll-up = { send = "item.volume", format = '+5%' } 296 | on-scroll-down = { send = "item.volume", format = '-5%' } 297 | 298 | # This uses two properties of the device to switch between four icons, 299 | # indicating if the device is muted or if something is recording. A lighter 300 | # green is used if recording a muted mic. 301 | [mic-s] 302 | type = 'switch' 303 | format = '{item.mute}-{item.active}' 304 | default = "" 305 | 306 | [mic-s.cases] 307 | 0-0 = "" 308 | 0-1 = "" 309 | 1-0 = "" 310 | 1-1 = "" 311 | 312 | [tray] 313 | type = "tray" 314 | 315 | # Change the background of the tray icons based on urgency 316 | [tray.item] 317 | type = 'icon' 318 | name = "{item.icon}" 319 | fallback = "{item.title}" 320 | bg = "#000f" 321 | 322 | [tray.urgent] 323 | type = 'icon' 324 | name = "{item.icon}" 325 | fallback = "{item.title}" 326 | bg = "#800f" 327 | 328 | # Inactive icons are displayed at about half size 329 | # Omit this section to just hide them 330 | [tray.passive] 331 | type = 'icon' 332 | name = "{item.icon}" 333 | fallback = "{item.title}" 334 | bg = "#000e" 335 | margin = "0 0 10 0" 336 | 337 | [weather-cmd] 338 | type = "exec-json" 339 | command = "/home/daniel/bin/weather-widget" 340 | # This script outputs lines like '{"temp": "16°", "emoji": "🌥"}' 341 | 342 | [weather] 343 | format = "{weather-cmd.emoji} {weather-cmd.temp}" 344 | markup = true 345 | 346 | [temp1] 347 | type = "thermal" 348 | #name = "Composite" 349 | path = "/sys/block/nvme0n1/device/hwmon*/temp1_input" 350 | poll = 20 351 | 352 | [hdd-icon] 353 | type = "meter" 354 | min = 20 355 | max = 50 356 | src = "{temp1}" 357 | below = "{temp1}°C" 358 | above = "{temp1}°C" 359 | values = ["", "", "", "", "", "", ""] 360 | 361 | [hdd-use] 362 | type = "disk" 363 | path = "/" 364 | 365 | [hdd] 366 | format = "/:{hdd-use} {hdd-icon}" 367 | markup = true 368 | 369 | #[bat-charge] 370 | #type = "read-file" 371 | #file = "/sys/class/power_supply/BAT1/charge_now" 372 | #poll = 20 373 | # 374 | #[bat-full] 375 | #type = "read-file" 376 | #file = "/sys/class/power_supply/BAT1/charge_full" 377 | #poll = 20 378 | 379 | #[bat_icon] 380 | #type = "meter" 381 | #min = 0 382 | #max = "{bat-full}" 383 | #src = "{bat-charge}" 384 | #values = ["", "", "", "", ""] 385 | # 386 | #[bat] 387 | #format = "{bat-charge} mAh {bat_icon}" 388 | 389 | [cpu_temp] 390 | type = "thermal" 391 | name = "Package id 0" 392 | #file = "/sys/class/hwmon/hwmon3/temp1_input" 393 | poll = 20 394 | 395 | [cpu_icon] 396 | type = "meter" 397 | min = 20 398 | max = 60 399 | src = "{cpu_temp}" 400 | below = "{cpu_temp}°C" 401 | above = "{cpu_temp}°C" 402 | values = ["", "", "", "", "", ""] 403 | 404 | [psi-cpu-file] 405 | type = "read-file" 406 | file = "/proc/pressure/cpu" 407 | poll = 5 408 | 409 | [psi-io-file] 410 | type = "read-file" 411 | file = "/proc/pressure/io" 412 | poll = 5 413 | 414 | [psi-memory-file] 415 | type = "read-file" 416 | file = "/proc/pressure/memory" 417 | poll = 5 418 | 419 | [psi-cpu-10] 420 | type = "regex" 421 | text = "{psi-cpu-file}" 422 | regex = 'some avg10=(\S+)' 423 | 424 | [psi-io-10] 425 | type = "regex" 426 | text = "{psi-io-file}" 427 | regex = 'some avg10=(\S+)' 428 | 429 | [cpu] 430 | format = "{psi-cpu-10.1} {psi-io-10.1} {cpu_icon}" 431 | 432 | [emoji] 433 | format = "⏱" 434 | 435 | # Note: choosing a font explicitly is not required if the default fonts do not 436 | # contain the requested glyph, but it may be useful in case more than one font 437 | # defines it. 438 | #font = "emoji" 439 | 440 | # Scrolling on the emoji will move it right or left (by adjusting the padding) 441 | padding = "0 0 0 {emoji_pad}" 442 | on-scroll-left = { send = "emoji_pad", format = "{=max(0,emoji_pad - 5)}" } 443 | on-scroll-right = { send = "emoji_pad", format = "{=min(200,emoji_pad + 5)}" } 444 | 445 | # Have a font tester as the tooltip to the emoji item 446 | tooltip = ['font-tester'] 447 | # Vertical scrolling on the emoji will change the font tester 448 | on-scroll-up = { write = "font-tester", "format" = "-32" } 449 | on-scroll-down = { write = "font-tester", "format" = "+32" } 450 | 451 | [font-tester] 452 | type = 'font-test' 453 | font = "emoji" 454 | 455 | [emoji_pad] 456 | value = 0 457 | 458 | [clip] 459 | #type = 'clipboard' 460 | format = "" 461 | 462 | [tvl] 463 | type = "group" 464 | fg = "white" 465 | bg = "black" 466 | bg-alpha = 0.5 467 | spacing = 1.5 468 | alpha = 0.7 469 | items = ["emoji", "mode", "workspaces-repr", "clip"] 470 | 471 | [tvr] 472 | type = "group" 473 | fg = "white" 474 | bg = "black" 475 | bg-alpha = "0.2" 476 | spacing = 15 477 | items = ["players", "weather", "tray", "pw-spk", "pw-mic", "hdd", "cpu" ] 478 | -------------------------------------------------------------------------------- /doc/sample-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldg/rwaybar/036805d0a2c3259154e1da6529673ce647fa5a29/doc/sample-black.png -------------------------------------------------------------------------------- /doc/sample-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldg/rwaybar/036805d0a2c3259154e1da6529673ce647fa5a29/doc/sample-white.png -------------------------------------------------------------------------------- /src/bar.rs: -------------------------------------------------------------------------------- 1 | use log::error; 2 | use smithay_client_toolkit::{ 3 | compositor::Region, 4 | output::OutputInfo, 5 | shell::{ 6 | wlr_layer::{Anchor, Layer, LayerSurface}, 7 | WaylandSurface, 8 | }, 9 | }; 10 | use std::{convert::TryInto, rc::Rc, time::Instant}; 11 | use wayland_client::protocol::wl_output::WlOutput; 12 | 13 | use crate::{ 14 | event::EventSink, 15 | item::*, 16 | render::{RenderSurface, Renderer}, 17 | state::{DrawNotifyHandle, InterestMask, Runtime}, 18 | util::{spawn_noerr, UID}, 19 | wayland::{Button, Popup, Scale120, SurfaceData, SurfaceEvents, WaylandClient}, 20 | }; 21 | 22 | #[derive(Debug)] 23 | pub struct BarPopup { 24 | pub wl: Popup, 25 | desc: PopupDesc, 26 | vanish: Option, 27 | render: RenderSurface, 28 | } 29 | 30 | /// A single taskbar on a single output 31 | #[derive(Debug)] 32 | pub struct Bar { 33 | pub name: Box, 34 | pub ls: LayerSurface, 35 | pub popup: Option, 36 | pub sink: EventSink, 37 | pub anchor_top: bool, 38 | click_size: u32, 39 | sparse: bool, 40 | pub item: Rc, 41 | pub cfg_index: usize, 42 | pub id: UID, 43 | 44 | render: RenderSurface, 45 | } 46 | 47 | impl Bar { 48 | pub fn new( 49 | wayland: &mut WaylandClient, 50 | output: &WlOutput, 51 | output_data: &OutputInfo, 52 | cfg: toml::Value, 53 | cfg_index: usize, 54 | ) -> Bar { 55 | let scale = Scale120::from_output(output_data.scale_factor); 56 | let layer = match cfg.get("layer").and_then(|v| v.as_str()) { 57 | Some("overlay") => Layer::Overlay, 58 | Some("bottom") => Layer::Bottom, 59 | Some("top") | None => Layer::Top, 60 | Some(layer) => { 61 | error!("Unknown layer '{layer}', defaulting to top"); 62 | Layer::Top 63 | } 64 | }; 65 | let size = cfg 66 | .get("size") 67 | .and_then(|v| v.as_integer()) 68 | .filter(|&v| v > 0 && v < i32::MAX as _) 69 | .and_then(|v| v.try_into().ok()) 70 | .unwrap_or(20); 71 | let size_excl = cfg 72 | .get("size-exclusive") 73 | .and_then(|v| v.as_integer()) 74 | .filter(|&v| v >= -1 && v < i32::MAX as _) 75 | .and_then(|v| v.try_into().ok()) 76 | .unwrap_or(size as i32); 77 | let click_size = cfg 78 | .get("size-clickable") 79 | .and_then(|v| v.as_integer()) 80 | .filter(|&v| v > 0 && v < i32::MAX as _) 81 | .and_then(|v| v.try_into().ok()) 82 | .or_else(|| size_excl.try_into().ok().filter(|&v| v > 0)) 83 | .unwrap_or(size); 84 | let anchor_top = match cfg.get("side").and_then(|v| v.as_str()) { 85 | Some("top") => true, 86 | None | Some("bottom") => false, 87 | Some(side) => { 88 | error!("Unknown side '{}', defaulting to bottom", side); 89 | false 90 | } 91 | }; 92 | 93 | let surf = wayland.create_surface(scale); 94 | let ls = wayland.layer.create_layer_surface( 95 | &wayland.queue, 96 | surf, 97 | layer, 98 | Some("bar"), 99 | Some(output), 100 | ); 101 | ls.set_size(0, size); 102 | ls.set_anchor(if anchor_top { 103 | Anchor::TOP | Anchor::LEFT | Anchor::RIGHT 104 | } else { 105 | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT 106 | }); 107 | ls.set_exclusive_zone(size_excl); 108 | let sparse = cfg 109 | .get("sparse-clicks") 110 | .and_then(|v| v.as_bool()) 111 | .unwrap_or(true); 112 | if size != click_size { 113 | // Only handle input in the exclusive region; clicks in the overhang region will go 114 | // through to the window we cover (hopefully transparently, to avoid confusion) 115 | let region = Region::new(&wayland.compositor).unwrap(); 116 | let yoff = if anchor_top { 117 | 0 118 | } else { 119 | size.saturating_sub(click_size) as i32 120 | }; 121 | if sparse { 122 | // start with an empty region to match the empty EventSink 123 | } else { 124 | region.add(0, yoff, i32::MAX, click_size as i32); 125 | } 126 | ls.wl_surface().set_input_region(Some(®ion.wl_region())); 127 | } 128 | ls.wl_surface().commit(); 129 | 130 | Bar { 131 | render: RenderSurface::new(), 132 | name: output_data.name.clone().unwrap_or_default().into(), 133 | ls, 134 | item: Rc::new(Item::new_bar(cfg)), 135 | click_size, 136 | anchor_top, 137 | sink: EventSink::default(), 138 | sparse, 139 | popup: None, 140 | cfg_index, 141 | id: UID::new(), 142 | } 143 | } 144 | 145 | pub fn render_with( 146 | &mut self, 147 | mask: InterestMask, 148 | runtime: &mut Runtime, 149 | renderer: &mut Renderer, 150 | ) { 151 | runtime.set_interest_mask(mask.bar_region(1)); 152 | runtime.items.insert("bar".into(), self.item.clone()); 153 | 154 | let surface_data = SurfaceData::from_wl(self.ls.wl_surface()); 155 | 156 | if surface_data.start_render() { 157 | let surf = self.ls.wl_surface(); 158 | renderer.render(runtime, surf, &mut self.render, |ctx| { 159 | let new_sink = self.item.render(ctx); 160 | 161 | if self.sparse { 162 | let mut old_regions = Vec::new(); 163 | let mut new_regions = Vec::new(); 164 | self.sink.for_active_regions(|lo, hi| { 165 | old_regions.push((lo as i32, (hi - lo) as i32)); 166 | }); 167 | new_sink.for_active_regions(|lo, hi| { 168 | new_regions.push((lo as i32, (hi - lo) as i32)); 169 | }); 170 | 171 | if old_regions != new_regions { 172 | let region = Region::new(&ctx.runtime.wayland.compositor).unwrap(); 173 | let yoff = if self.anchor_top { 174 | 0 175 | } else { 176 | surface_data.height().saturating_sub(self.click_size) as i32 177 | }; 178 | for (lo, len) in new_regions { 179 | region.add(lo, yoff, len, self.click_size as i32); 180 | } 181 | surf.set_input_region(Some(region.wl_region())); 182 | } 183 | } 184 | self.sink = new_sink; 185 | true 186 | }); 187 | } 188 | 189 | if let Some(popup) = &mut self.popup { 190 | if popup.vanish.map_or(false, |vanish| vanish < Instant::now()) { 191 | self.popup = None; 192 | } 193 | } 194 | let mut scale = Scale120::default(); 195 | let popup = self.popup.as_mut().and_then(|popup| { 196 | let surface_data = SurfaceData::from_wl(&popup.wl.surf); 197 | if surface_data.start_render() { 198 | scale = surface_data.scale_120(); 199 | Some(popup) 200 | } else { 201 | None 202 | } 203 | }); 204 | 205 | runtime.set_interest_mask(mask.bar_region(2)); 206 | if let Some(popup) = popup { 207 | let surf = popup.wl.surf.clone(); 208 | renderer.render(runtime, &surf, &mut popup.render, |ctx| { 209 | let new_size = popup.desc.render_popup(ctx); 210 | 211 | if new_size.0 > popup.wl.req_size.0 212 | || new_size.1 > popup.wl.req_size.1 213 | || new_size.0 + 10 < popup.wl.req_size.0 214 | || new_size.1 + 10 < popup.wl.req_size.1 215 | { 216 | // Abort drawing the frame and request a resize instead 217 | match self.ls.kind() { 218 | smithay_client_toolkit::shell::wlr_layer::SurfaceKind::Wlr(ls) => { 219 | popup.wl.resize(&runtime.wayland, &ls, new_size, scale); 220 | return false; 221 | } 222 | _ => unreachable!(), 223 | } 224 | } 225 | true 226 | }); 227 | } 228 | } 229 | } 230 | 231 | impl SurfaceEvents for Bar { 232 | fn hover(&mut self, (x, y): (f64, f64), runtime: &mut Runtime, render: &mut Renderer) { 233 | if let Some((min_x, max_x, desc)) = self.sink.get_hover(x as f32, y as f32) { 234 | if let Some(popup) = &mut self.popup { 235 | if x < popup.wl.anchor.0 as f64 236 | || x > (popup.wl.anchor.0 + popup.wl.anchor.2) as f64 237 | { 238 | self.popup = None; 239 | } else if popup.desc == *desc { 240 | return; 241 | } else { 242 | self.popup = None; 243 | } 244 | } 245 | let surf_data = SurfaceData::from_wl(self.ls.wl_surface()); 246 | let anchor = ( 247 | min_x as i32, 248 | 0, 249 | (max_x - min_x) as i32, 250 | surf_data.height() as i32, 251 | ); 252 | 253 | runtime.items.insert("bar".into(), self.item.clone()); 254 | let size = render.render_dummy(runtime, |ctx| desc.render_popup(ctx)); 255 | if size.0 <= 0 || size.1 <= 0 { 256 | return; 257 | } 258 | let desc = desc.clone(); 259 | 260 | let wl = Popup::on_layer(&runtime.wayland, &self.ls, !self.anchor_top, anchor, size); 261 | 262 | let popup = BarPopup { 263 | wl, 264 | desc, 265 | vanish: None, 266 | render: RenderSurface::new(), 267 | }; 268 | self.popup = Some(popup); 269 | } 270 | } 271 | 272 | fn no_hover(&mut self, runtime: &mut Runtime) { 273 | if let Some(popup) = &mut self.popup { 274 | let vanish = Instant::now() + std::time::Duration::from_millis(100); 275 | popup.vanish = Some(vanish); 276 | let notify = DrawNotifyHandle::new(runtime); 277 | spawn_noerr(async move { 278 | tokio::time::sleep_until(vanish.into()).await; 279 | // A redraw will remove the popup if the vanish deadline has passed 280 | notify.notify_draw_only(); 281 | }); 282 | } 283 | } 284 | 285 | fn button(&mut self, (x, y): (f64, f64), button: Button, runtime: &mut Runtime) { 286 | self.sink.button(x as f32, y as f32, button, runtime); 287 | } 288 | } 289 | 290 | impl SurfaceEvents for BarPopup { 291 | fn hover(&mut self, _: (f64, f64), _: &mut Runtime, _: &mut Renderer) { 292 | self.vanish = None; 293 | } 294 | 295 | fn no_hover(&mut self, runtime: &mut Runtime) { 296 | let vanish = Instant::now() + std::time::Duration::from_millis(100); 297 | self.vanish = Some(vanish); 298 | let notify = DrawNotifyHandle::new(runtime); 299 | spawn_noerr(async move { 300 | tokio::time::sleep_until(vanish.into()).await; 301 | // A redraw will remove the popup if the vanish deadline has passed 302 | notify.notify_draw_only(); 303 | }); 304 | } 305 | 306 | fn button(&mut self, (x, y): (f64, f64), button: Button, runtime: &mut Runtime) { 307 | self.desc.button(x, y, button, runtime); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/dbus.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | data::Value, 3 | state::{NotifierList, Runtime}, 4 | util, 5 | util::{spawn_noerr, Cell}, 6 | }; 7 | use async_once_cell::Lazy; 8 | use futures_channel::mpsc::{self, UnboundedSender}; 9 | use futures_util::{future::RemoteHandle, StreamExt}; 10 | use log::{error, info}; 11 | use std::{ 12 | cell::{OnceCell, RefCell}, 13 | collections::HashMap, 14 | fmt, 15 | rc::Rc, 16 | }; 17 | use zbus::{zvariant, Connection}; 18 | use zvariant::{OwnedValue, Value as Variant}; 19 | 20 | pub struct DBus { 21 | send: UnboundedSender, 22 | 23 | bus: Lazy, RemoteHandle>>, 24 | } 25 | 26 | impl fmt::Debug for DBus { 27 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 28 | write!(fmt, "DBus") 29 | } 30 | } 31 | 32 | thread_local! { 33 | static SESSION : OnceCell> = Default::default(); 34 | static SYSTEM : OnceCell> = Default::default(); 35 | } 36 | 37 | impl DBus { 38 | pub fn get_session() -> Rc { 39 | SESSION.with(|s| s.get_or_init(|| Self::new(true)).clone()) 40 | } 41 | 42 | pub fn get_system() -> Rc { 43 | SYSTEM.with(|s| s.get_or_init(|| Self::new(false)).clone()) 44 | } 45 | 46 | pub async fn connection(&self) -> Result<&Connection, zbus::Error> { 47 | (&self.bus).await.as_ref().map_err(Clone::clone) 48 | } 49 | 50 | fn new(is_session: bool) -> Rc { 51 | let (send, mut recv) = mpsc::unbounded(); 52 | let (init, rh) = futures_util::FutureExt::remote_handle(async move { 53 | use zbus::conn::Builder; 54 | let builder = if is_session { 55 | Builder::session()? 56 | } else { 57 | Builder::system()? 58 | }; 59 | 60 | builder.internal_executor(false).build().await 61 | }); 62 | 63 | let tb = Rc::new(DBus { 64 | send, 65 | bus: Lazy::new(rh), 66 | }); 67 | 68 | let this = tb.clone(); 69 | util::spawn("DBus Sender", async move { 70 | init.await; 71 | let zbus = this.connection().await?; 72 | while let Some(msg) = recv.next().await { 73 | zbus.send(&msg).await?; 74 | } 75 | Ok(()) 76 | }); 77 | 78 | tb 79 | } 80 | 81 | pub fn send(&self, msg: zbus::Message) { 82 | let _ = self.send.unbounded_send(msg); 83 | } 84 | } 85 | 86 | /// The "dbus" block 87 | #[derive(Debug)] 88 | pub struct DbusValue { 89 | bus: Rc, 90 | bus_name: Box, 91 | path: Box, 92 | interface: Box, 93 | member: Box, 94 | args: Box<[toml::Value]>, 95 | sig: Cell>>, 96 | value: RefCell>, 97 | interested: NotifierList, 98 | watch: Cell>>, 99 | } 100 | 101 | impl DbusValue { 102 | pub fn from_toml(value: &toml::Value) -> Result, &'static str> { 103 | let dbus = match value.get("bus").and_then(|v| v.as_str()) { 104 | None | Some("session") => DBus::get_session(), 105 | Some("system") => DBus::get_system(), 106 | _ => { 107 | return Err("bus must be either 'session' or 'system'"); 108 | } 109 | }; 110 | let bus_name = value 111 | .get("owner") 112 | .and_then(|v| v.as_str()) 113 | .unwrap_or("") 114 | .into(); 115 | let path = value 116 | .get("path") 117 | .and_then(|v| v.as_str()) 118 | .unwrap_or("") 119 | .into(); 120 | let method = value.get("method").and_then(|v| v.as_str()); 121 | let property = value.get("property").and_then(|v| v.as_str()); 122 | let (interface, member, args); 123 | match ( 124 | method.map(|s| s.rsplit_once(".")), 125 | property.map(|s| s.rsplit_once(".")), 126 | ) { 127 | (Some(_), Some(_)) => { 128 | return Err("dbus cannot query both a property and a method"); 129 | } 130 | (Some(Some((i, m))), None) => { 131 | interface = i.into(); 132 | member = m.into(); 133 | args = value 134 | .get("args") 135 | .and_then(|v| v.as_array()) 136 | .cloned() 137 | .unwrap_or_default() 138 | .into_boxed_slice(); 139 | } 140 | (None, Some(Some((i, p)))) => { 141 | interface = "org.freedesktop.DBus.Properties".into(); 142 | member = "Get".into(); 143 | args = Box::new([i.into(), p.into()]); 144 | } 145 | _ => { 146 | return Err("dbus requires a member or property to query"); 147 | } 148 | } 149 | 150 | let rc = Rc::new(DbusValue { 151 | bus: dbus.clone(), 152 | bus_name, 153 | path, 154 | interface, 155 | member, 156 | args, 157 | value: RefCell::new(None), 158 | sig: Default::default(), 159 | interested: Default::default(), 160 | watch: Default::default(), 161 | }); 162 | 163 | let watch_path = value.get("watch-path").and_then(|v| v.as_str()); 164 | let watch_method = value.get("watch-method").and_then(|v| v.as_str()); 165 | match watch_method.map(|s| s.rsplit_once(".")) { 166 | Some(Some((i, m))) => { 167 | let mut expr = format!("type='signal',interface='{}',member='{}'", i, m); 168 | if let Some(path) = &watch_path { 169 | expr.push_str(",path='"); 170 | expr.push_str(path); 171 | expr.push_str("'"); 172 | } 173 | // TODO remove matches when this object is dropped 174 | dbus.send( 175 | zbus::Message::method("/org/freedesktop/DBus", "AddMatch") 176 | .unwrap() 177 | .destination("org.freedesktop.DBus") 178 | .unwrap() 179 | .interface("org.freedesktop.DBus") 180 | .unwrap() 181 | .build(&expr) 182 | .unwrap(), 183 | ); 184 | 185 | let watch_path: Option> = watch_path.map(Into::into); 186 | let watch_method = watch_method.unwrap().to_owned(); 187 | let weak = Rc::downgrade(&rc); 188 | let h = util::spawn_handle("DBus value watcher", async move { 189 | let zbus = dbus.connection().await?; 190 | let mut stream = zbus::MessageStream::from(zbus); 191 | while let Some(Ok(msg)) = stream.next().await { 192 | if let Some(rc) = weak.upgrade() { 193 | let (i, m) = watch_method.rsplit_once(".").unwrap(); 194 | if msg.header().interface().map(|n| n.as_str()) != Some(i) 195 | || msg.header().member().map(|n| n.as_str()) != Some(m) 196 | { 197 | continue; 198 | } 199 | if let Some(path) = &watch_path { 200 | if msg.header().path().map(|p| p.as_str()) != Some(&*path) { 201 | continue; 202 | } 203 | } 204 | rc.call_now(); 205 | } 206 | } 207 | Ok(()) 208 | }); 209 | rc.watch.set(Some(h)); 210 | } 211 | Some(None) if watch_method == Some("") => {} 212 | Some(None) => error!("Invalid dbus watch expression, ignoring"), 213 | None if property.is_some() => { 214 | let prop = property.unwrap_or_default().to_owned(); 215 | 216 | let expr = format!("type='signal',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path='{}',arg0='{}'", 217 | rc.path, rc.args[0].as_str().unwrap(), 218 | ); 219 | dbus.send( 220 | zbus::Message::method("/org/freedesktop/DBus", "AddMatch") 221 | .unwrap() 222 | .destination("org.freedesktop.DBus") 223 | .unwrap() 224 | .interface("org.freedesktop.DBus") 225 | .unwrap() 226 | .build(&expr) 227 | .unwrap(), 228 | ); 229 | 230 | let weak = Rc::downgrade(&rc); 231 | 232 | let h = util::spawn_handle("DBus value watcher", async move { 233 | let zbus = dbus.connection().await?; 234 | let mut stream = zbus::MessageStream::from(zbus); 235 | while let Some(Ok(msg)) = stream.next().await { 236 | let hdr = msg.header(); 237 | if hdr.interface().map(|n| n.as_str()) 238 | != Some("org.freedesktop.DBus.Properties") 239 | || hdr.member().map(|n| n.as_str()) != Some("PropertiesChanged") 240 | { 241 | continue; 242 | } 243 | if let Some(rc) = weak.upgrade() { 244 | if msg.header().path().map(|p| p.as_str()) != Some(&*rc.path) { 245 | continue; 246 | } 247 | 248 | let body = msg.body(); 249 | let (iface, changed, inval): ( 250 | &str, 251 | HashMap<&str, OwnedValue>, 252 | Vec<&str>, 253 | ) = body.deserialize()?; 254 | 255 | if iface != rc.args[0].as_str().unwrap() { 256 | continue; 257 | } 258 | 259 | if let Some(value) = changed.get(&*prop) { 260 | // match the Get return type 261 | let v = Variant::Structure( 262 | zvariant::StructureBuilder::new() 263 | .append_field((**value).try_clone().unwrap()) 264 | .build(), 265 | ) 266 | .try_to_owned() 267 | .unwrap(); 268 | *rc.value.borrow_mut() = Some(v); 269 | } else if inval.iter().any(|p| p == &prop) { 270 | *rc.value.borrow_mut() = None; 271 | } 272 | } 273 | } 274 | Ok(()) 275 | }); 276 | rc.watch.set(Some(h)); 277 | } 278 | None => {} 279 | } 280 | Ok(rc) 281 | } 282 | 283 | fn call_now(self: Rc) { 284 | spawn_noerr(async move { 285 | self.do_call().await; 286 | }); 287 | } 288 | 289 | pub async fn do_call(self: Rc) { 290 | match self.try_call().await { 291 | Ok(()) => (), 292 | Err(e) => log::debug!("DBus error: {}", e), 293 | } 294 | } 295 | 296 | async fn try_call(self: Rc) -> zbus::Result<()> { 297 | use toml::value::Value; 298 | let dbus = &*self.bus; 299 | let zbus = dbus.connection().await?; 300 | 301 | let mut api = self.sig.take_in(|s| s.clone()); 302 | 303 | if api.is_none() { 304 | let msg = zbus 305 | .call_method( 306 | Some(&*self.bus_name), 307 | &*self.path, 308 | Some("org.freedesktop.DBus.Introspectable"), 309 | "Introspect", 310 | &(), 311 | ) 312 | .await; 313 | match msg.as_ref().map(|m| m.body()) { 314 | Ok(body) => { 315 | let xml = body.deserialize()?; 316 | let mut reader = xml::EventReader::from_str(xml); 317 | let mut sig = String::new(); 318 | let mut in_iface = false; 319 | let mut in_method = false; 320 | loop { 321 | use xml::reader::XmlEvent; 322 | match reader.next() { 323 | Ok(XmlEvent::StartElement { 324 | name, attributes, .. 325 | }) if name.local_name == "interface" => { 326 | in_iface = attributes.iter().any(|attr| { 327 | attr.name.local_name == "name" && attr.value == &*self.interface 328 | }); 329 | } 330 | Ok(XmlEvent::EndElement { name }) if name.local_name == "interface" => { 331 | in_iface = false; 332 | } 333 | Ok(XmlEvent::StartElement { 334 | name, attributes, .. 335 | }) if name.local_name == "method" => { 336 | in_method = attributes.iter().any(|attr| { 337 | attr.name.local_name == "name" && attr.value == &*self.member 338 | }); 339 | } 340 | Ok(XmlEvent::EndElement { name }) if name.local_name == "interface" => { 341 | in_method = false; 342 | } 343 | Ok(XmlEvent::StartElement { 344 | name, attributes, .. 345 | }) if in_iface 346 | && in_method 347 | && name.local_name == "arg" 348 | && attributes.iter().any(|attr| { 349 | attr.name.local_name == "direction" && attr.value == "in" 350 | }) => 351 | { 352 | for attr in attributes { 353 | if attr.name.local_name == "type" { 354 | sig.push_str(&attr.value); 355 | sig.push_str(","); 356 | } 357 | } 358 | } 359 | Ok(XmlEvent::EndDocument) => { 360 | sig.pop(); 361 | api = Some(sig.into()); 362 | self.sig.set(api.clone()); 363 | break; 364 | } 365 | Ok(_) => {} 366 | Err(e) => { 367 | info!("Error introspecting {} {}: {}", self.bus_name, self.path, e); 368 | break; 369 | } 370 | } 371 | } 372 | } 373 | Err(e) => { 374 | info!("Error introspecting {} {}: {}", self.bus_name, self.path, e); 375 | } 376 | } 377 | } 378 | 379 | let mut args = zvariant::StructureBuilder::new(); 380 | if let Some(sig) = api { 381 | for (ty, arg) in sig.split(",").zip(self.args.iter()) { 382 | match match ty { 383 | "s" => arg.as_str().map(|s| args.push_field(s)), 384 | "d" => arg 385 | .as_float() 386 | .or_else(|| arg.as_integer().map(|i| i as f64)) 387 | .map(|f| args.push_field(f)), 388 | "y" => arg 389 | .as_integer() 390 | .map(|i| i as u8) 391 | .or_else(|| arg.as_float().map(|f| f as _)) 392 | .map(|i| args.push_field(i)), 393 | "n" => arg 394 | .as_integer() 395 | .map(|i| i as i16) 396 | .or_else(|| arg.as_float().map(|f| f as _)) 397 | .map(|i| args.push_field(i)), 398 | "q" => arg 399 | .as_integer() 400 | .map(|i| i as u16) 401 | .or_else(|| arg.as_float().map(|f| f as _)) 402 | .map(|i| args.push_field(i)), 403 | "i" => arg 404 | .as_integer() 405 | .map(|i| i as i32) 406 | .or_else(|| arg.as_float().map(|f| f as _)) 407 | .map(|i| args.push_field(i)), 408 | "u" => arg 409 | .as_integer() 410 | .map(|i| i as u32) 411 | .or_else(|| arg.as_float().map(|f| f as _)) 412 | .map(|i| args.push_field(i)), 413 | "x" => arg 414 | .as_integer() 415 | .map(|i| i as i64) 416 | .or_else(|| arg.as_float().map(|f| f as _)) 417 | .map(|i| args.push_field(i)), 418 | "t" => arg 419 | .as_integer() 420 | .map(|i| i as u64) 421 | .or_else(|| arg.as_float().map(|f| f as _)) 422 | .map(|i| args.push_field(i)), 423 | "b" => arg 424 | .as_bool() 425 | .or_else(|| arg.as_integer().map(|i| i != 0)) 426 | .or_else(|| arg.as_float().map(|i| i != 0.0)) 427 | .map(|b| args.push_field(b)), 428 | "v" => match arg { 429 | // best guess as to the type of a variant parameter 430 | Value::String(s) => Some(args.push_field(Variant::from(s))), 431 | Value::Integer(i) => Some(args.push_field(Variant::from(i))), 432 | Value::Float(f) => Some(args.push_field(Variant::from(f))), 433 | Value::Boolean(b) => Some(args.push_field(Variant::from(b))), 434 | _ => None, 435 | }, 436 | _ => None, 437 | } { 438 | Some(()) => {} 439 | None => { 440 | info!( 441 | "Unsupported or mismatching dbus argument: expected type '{}' got '{}'", 442 | ty, arg 443 | ); 444 | } 445 | } 446 | } 447 | } else { 448 | // no introspection, we just have to guess the argument types 449 | for arg in self.args.iter() { 450 | match arg { 451 | Value::String(s) => args.push_field(s), 452 | Value::Integer(i) => args.push_field(i), 453 | Value::Float(f) => args.push_field(f), 454 | Value::Boolean(b) => args.push_field(b), 455 | _ => { 456 | info!("Invalid dbus argument '{}'", arg); 457 | } 458 | } 459 | } 460 | } 461 | let args = args.build(); 462 | let reply = zbus 463 | .call_method( 464 | Some(&*self.bus_name), 465 | &*self.path, 466 | Some(&*self.interface), 467 | &*self.member, 468 | &args, 469 | ) 470 | .await?; 471 | 472 | *self.value.borrow_mut() = 473 | Some(Variant::Structure(reply.body().deserialize()?).try_to_owned()?); 474 | 475 | self.interested.notify_data("dbus-read"); 476 | Ok(()) 477 | } 478 | 479 | fn read_variant<'a, F: FnOnce(Value) -> R, R>( 480 | value: &Variant, 481 | mut keys: impl Iterator, 482 | rt: &Runtime, 483 | f: F, 484 | ) -> R { 485 | match value { 486 | Variant::Bool(b) => f(Value::Bool(*b)), 487 | Variant::U8(v) => f(Value::Float(*v as _)), 488 | Variant::I16(v) => f(Value::Float(*v as _)), 489 | Variant::U16(v) => f(Value::Float(*v as _)), 490 | Variant::I32(v) => f(Value::Float(*v as _)), 491 | Variant::U32(v) => f(Value::Float(*v as _)), 492 | Variant::I64(v) => f(Value::Float(*v as _)), 493 | Variant::U64(v) => f(Value::Float(*v as _)), 494 | Variant::F64(v) => f(Value::Float(*v)), 495 | Variant::Str(s) => f(Value::Borrow(s)), 496 | Variant::Signature(s) => f(Value::Borrow(s)), 497 | Variant::ObjectPath(s) => f(Value::Borrow(s)), 498 | Variant::Value(v) => Self::read_variant(v, keys, rt, f), 499 | Variant::Array(a) => { 500 | match keys 501 | .next() 502 | .unwrap_or("0") 503 | .parse::() 504 | .ok() 505 | .and_then(|i| a.get(i).ok().flatten()) 506 | { 507 | Some(v) => Self::read_variant(v, keys, rt, f), 508 | None => f(Value::Null), 509 | } 510 | } 511 | Variant::Dict(d) => { 512 | let key = match keys.next() { 513 | Some(k) => k, 514 | None => return f(Value::Null), 515 | }; 516 | // sig is "a{sv}" or "a{oa...}" 517 | let sig = d.full_signature().as_bytes(); 518 | let v = match sig.get(2) { 519 | Some(b's') => d.get(&key).ok().flatten().map(Variant::try_to_owned), 520 | Some(b'o') => d 521 | .get(&zvariant::ObjectPath::from_str_unchecked(key)) 522 | .ok() 523 | .flatten() 524 | .map(Variant::try_to_owned), 525 | Some(b'g') => d 526 | .get(&zvariant::Signature::from_str_unchecked(key)) 527 | .ok() 528 | .flatten() 529 | .map(Variant::try_to_owned), 530 | _ => { 531 | info!( 532 | "Unsupported dict key in type: '{}'", 533 | d.full_signature().as_str() 534 | ); 535 | return f(Value::Null); 536 | } 537 | }; 538 | match v { 539 | Some(Ok(v)) => Self::read_variant(&*v, keys, rt, f), 540 | _ => f(Value::Null), 541 | } 542 | } 543 | Variant::Structure(s) => { 544 | let i = keys 545 | .next() 546 | .unwrap_or("0") 547 | .parse::() 548 | .ok() 549 | .unwrap_or(0); 550 | match s.fields().get(i) { 551 | Some(v) => Self::read_variant(v, keys, rt, f), 552 | None => f(Value::Null), 553 | } 554 | } 555 | Variant::Fd(_) => f(Value::Null), 556 | } 557 | } 558 | 559 | pub fn read_in R, R>(&self, key: &str, rt: &Runtime, f: F) -> R { 560 | self.interested.add(rt); 561 | let value = self.value.borrow(); 562 | match value.as_deref() { 563 | Some(value) => Self::read_variant(value, key.split("."), rt, f), 564 | None => f(Value::Null), 565 | } 566 | } 567 | } 568 | -------------------------------------------------------------------------------- /src/event.rs: -------------------------------------------------------------------------------- 1 | //! Event handling (click, scroll) 2 | #[cfg(feature = "dbus")] 3 | use crate::tray; 4 | use crate::{data::IterationItem, item::PopupDesc, state::Runtime, wayland::Button}; 5 | use log::{error, info}; 6 | use std::{process::Command, rc::Rc}; 7 | 8 | /// A single click action associated with the area that activates it 9 | #[derive(Debug, Clone)] 10 | struct EventListener { 11 | x_min: f32, 12 | x_max: f32, 13 | buttons: u32, 14 | item: Option, 15 | target: Action, 16 | } 17 | 18 | /// A list of [EventListener]s 19 | #[derive(Debug, Default, Clone)] 20 | pub struct EventSink { 21 | handlers: Vec, 22 | hovers: Vec<(f32, f32, PopupDesc)>, 23 | } 24 | 25 | impl EventSink { 26 | pub fn from_toml(value: &toml::Value) -> Self { 27 | let mut sink = EventSink::default(); 28 | sink.add_click(value.get("on-click"), 1 << 0 | 1 << 9); 29 | sink.add_click(value.get("on-click-left"), 1 << 0); 30 | sink.add_click(value.get("on-click-right"), 1 << 1); 31 | sink.add_click(value.get("on-click-middle"), 1 << 2); 32 | sink.add_click(value.get("on-click-back"), 1 << 3); 33 | sink.add_click(value.get("on-click-backward"), 1 << 3); 34 | sink.add_click(value.get("on-click-forward"), 1 << 4); 35 | sink.add_click(value.get("on-scroll-up"), 1 << 5); 36 | sink.add_click(value.get("on-scroll-down"), 1 << 6); 37 | sink.add_click(value.get("on-vscroll"), 3 << 5); 38 | sink.add_click(value.get("on-scroll-left"), 1 << 7); 39 | sink.add_click(value.get("on-scroll-right"), 1 << 8); 40 | sink.add_click(value.get("on-hscroll"), 3 << 7); 41 | sink.add_click(value.get("on-scroll"), 15 << 5); 42 | sink.add_click(value.get("on-tap"), 1 << 9); 43 | sink 44 | } 45 | 46 | fn add_click(&mut self, value: Option<&toml::Value>, buttons: u32) { 47 | if let Some(value) = value { 48 | self.handlers.push(EventListener { 49 | x_min: 0.0, 50 | x_max: 1e20, 51 | buttons, 52 | item: None, 53 | target: Action::from_toml(value), 54 | }) 55 | } 56 | } 57 | 58 | #[cfg(feature = "dbus")] 59 | pub fn from_tray(item: Rc) -> Self { 60 | let mut sink = EventSink::default(); 61 | sink.handlers.push(EventListener { 62 | x_min: -1e20, 63 | x_max: 1e20, 64 | buttons: 7 | (15 << 5), 65 | item: None, 66 | target: Action::from_tray(item), 67 | }); 68 | sink 69 | } 70 | 71 | pub fn add_tooltip(&mut self, desc: PopupDesc) { 72 | self.hovers.push((0.0, 1e20, desc)); 73 | } 74 | 75 | pub fn set_item(&mut self, item: &IterationItem) { 76 | for h in &mut self.handlers { 77 | h.item.get_or_insert_with(|| item.clone()); 78 | } 79 | } 80 | 81 | pub fn merge(&mut self, sink: Self) { 82 | self.handlers.extend(sink.handlers); 83 | self.hovers.extend(sink.hovers); 84 | } 85 | 86 | pub fn offset_clamp(&mut self, offset: f32, min: f32, max: f32) { 87 | for h in &mut self.handlers { 88 | h.x_min += offset; 89 | h.x_max += offset; 90 | if h.x_min < min { 91 | h.x_min = min; 92 | } else if h.x_min > max { 93 | h.x_min = max; 94 | } 95 | if h.x_max < min { 96 | h.x_max = min; 97 | } else if h.x_max > max { 98 | h.x_max = max; 99 | } 100 | } 101 | for (x_min, x_max, _) in &mut self.hovers { 102 | *x_min += offset; 103 | *x_max += offset; 104 | if *x_min < min { 105 | *x_min = min; 106 | } else if *x_min > max { 107 | *x_min = max; 108 | } 109 | if *x_max < min { 110 | *x_max = min; 111 | } else if *x_max > max { 112 | *x_max = max; 113 | } 114 | } 115 | } 116 | 117 | pub fn button(&self, x: f32, y: f32, button: Button, runtime: &mut Runtime) { 118 | let button = button as u32; 119 | let _ = y; 120 | for h in &self.handlers { 121 | if x < h.x_min || x > h.x_max { 122 | continue; 123 | } 124 | if (h.buttons & (1 << button)) == 0 { 125 | continue; 126 | } 127 | if h.item.is_none() { 128 | h.target.invoke(runtime, button); 129 | } else { 130 | let item_var = runtime.get_item_var(); 131 | item_var.set(h.item.clone()); 132 | h.target.invoke(runtime, button); 133 | item_var.set(None); 134 | } 135 | } 136 | } 137 | 138 | #[cfg_attr(not(feature = "dbus"), allow(unused))] 139 | pub fn add_hover(&mut self, min: f32, max: f32, desc: PopupDesc) { 140 | self.hovers.push((min, max, desc)); 141 | } 142 | 143 | pub fn get_hover(&mut self, x: f32, y: f32) -> Option<(f32, f32, &mut PopupDesc)> { 144 | let _ = y; 145 | for &mut (min, max, ref mut text) in &mut self.hovers { 146 | if x >= min && x < max { 147 | text.lazy_refresh(); 148 | return Some((min, max, text)); 149 | } 150 | } 151 | None 152 | } 153 | 154 | pub fn for_active_regions(&self, mut f: impl FnMut(f32, f32)) { 155 | let mut ha = self.handlers.iter().peekable(); 156 | let mut ho = self.hovers.iter().peekable(); 157 | loop { 158 | let a_min = ha.peek().map_or(f32::NAN, |e| e.x_min); 159 | let o_min = ho.peek().map_or(f32::NAN, |e| e.0); 160 | let min = f32::min(a_min, o_min); 161 | let mut max; 162 | if min == a_min { 163 | max = ha.next().unwrap().x_max; 164 | } else if min == o_min { 165 | max = ho.next().unwrap().1; 166 | } else { 167 | // NaN 168 | return; 169 | } 170 | loop { 171 | if ha.peek().map_or(f32::NAN, |e| e.x_min) <= max + 1.0 { 172 | max = f32::max(max, ha.next().map_or(f32::NAN, |e| e.x_max)); 173 | } else if ho.peek().map_or(f32::NAN, |e| e.0) <= max + 1.0 { 174 | max = f32::max(max, ho.next().map_or(f32::NAN, |e| e.1)); 175 | } else { 176 | break; 177 | } 178 | } 179 | f(min, max); 180 | } 181 | } 182 | } 183 | 184 | /// Handler invoked by a click or touch event 185 | #[derive(Debug, Clone)] 186 | pub enum Action { 187 | Exec { 188 | format: String, 189 | }, 190 | Write { 191 | target: String, 192 | format: String, 193 | }, 194 | List(Vec), 195 | #[cfg(feature = "dbus")] 196 | Tray(Rc), 197 | None, 198 | } 199 | 200 | impl Action { 201 | pub fn from_toml(value: &toml::Value) -> Self { 202 | if let Some(array) = value.as_array() { 203 | return Action::List(array.iter().map(Action::from_toml).collect()); 204 | } 205 | if let Some(dest) = value 206 | .get("write") 207 | .and_then(|v| v.as_str()) 208 | .or_else(|| value.get("send").and_then(|v| v.as_str())) 209 | { 210 | let format = value 211 | .get("format") 212 | .and_then(|v| v.as_str()) 213 | .or_else(|| value.get("msg").and_then(|v| v.as_str())) 214 | .unwrap_or("") 215 | .to_owned(); 216 | return Action::Write { 217 | target: dest.into(), 218 | format, 219 | }; 220 | } 221 | if let Some(cmd) = value.get("exec").and_then(|v| v.as_str()) { 222 | return Action::Exec { format: cmd.into() }; 223 | } 224 | error!("Unknown action: {}", value); 225 | Action::None 226 | } 227 | 228 | #[cfg(feature = "dbus")] 229 | pub fn from_tray(item: Rc) -> Self { 230 | Action::Tray(item) 231 | } 232 | 233 | pub fn invoke(&self, runtime: &Runtime, how: u32) { 234 | match self { 235 | Action::List(actions) => { 236 | for action in actions { 237 | action.invoke(runtime, how); 238 | } 239 | } 240 | Action::Write { target, format } => { 241 | let value = match runtime.format(&format) { 242 | Ok(value) => value, 243 | Err(e) => { 244 | error!("Error expanding format for command: {}", e); 245 | return; 246 | } 247 | }; 248 | 249 | let (name, key) = match target.find('.') { 250 | Some(p) => (&target[..p], &target[p + 1..]), 251 | None => (&target[..], ""), 252 | }; 253 | 254 | match runtime.items.get(name) { 255 | Some(item) => { 256 | item.data.write(name, key, value, &runtime); 257 | } 258 | None => error!("Could not find variable {}", target), 259 | } 260 | } 261 | Action::Exec { format } => match runtime.format(&format) { 262 | Ok(cmd) => { 263 | let cmd = cmd.into_text(); 264 | info!("Executing '{}'", cmd); 265 | match Command::new("/bin/sh").arg("-c").arg(&cmd[..]).spawn() { 266 | Ok(child) => drop(child), 267 | Err(e) => error!("Could not execute {}: {}", cmd, e), 268 | } 269 | } 270 | Err(e) => { 271 | error!("Error expanding format for command: {}", e); 272 | } 273 | }, 274 | #[cfg(feature = "dbus")] 275 | Action::Tray(item) => { 276 | tray::do_click(item, how); 277 | } 278 | Action::None => { 279 | info!("Invoked a no-op"); 280 | } 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/font.rs: -------------------------------------------------------------------------------- 1 | use crate::{icon::OwnedImage, item::Formatting, render::Render, state::Runtime, util::UID}; 2 | use log::info; 3 | use std::{fs::File, io, path::PathBuf, sync::Arc, time::Instant}; 4 | use tiny_skia::{Color, Point, Transform}; 5 | use ttf_parser::{Face, GlyphId}; 6 | 7 | #[derive(Debug)] 8 | pub struct FontMapped { 9 | // Note: lifetime is actually tied to mmap, not 'static 10 | parsed: Face<'static>, 11 | // this field must follow parsed for safety (drop order) 12 | #[allow(unused)] 13 | mmap: memmap2::Mmap, 14 | pub file: PathBuf, 15 | pub name: String, 16 | pub uid: UID, 17 | } 18 | 19 | impl FontMapped { 20 | pub fn new(name: String, path: PathBuf) -> io::Result { 21 | let file = File::open(&path)?; 22 | // rust's memory model requires that the backing file not be modified while in use; this is 23 | // generally not a concern for font files, but could in theory cause issues 24 | let mmap = unsafe { memmap2::Mmap::map(&file)? }; 25 | // forge a static lifetime, safe if accessed via public API 26 | let buf = unsafe { &*(mmap.as_ref() as *const [u8]) }; 27 | let parsed = 28 | Face::parse(&buf, 0).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; 29 | let uid = UID::new(); 30 | Ok(FontMapped { 31 | parsed, 32 | mmap, 33 | file: path, 34 | name, 35 | uid, 36 | }) 37 | } 38 | 39 | pub fn as_ref<'a>(&'a self) -> &'a Face<'a> { 40 | &self.parsed 41 | } 42 | 43 | pub fn scale_from_pt(&self, pt: f32) -> f32 { 44 | pt * 1.33333333 / self.as_ref().units_per_em() as f32 45 | } 46 | } 47 | 48 | #[derive(Debug, Clone)] 49 | pub struct CGlyph<'a> { 50 | pub id: GlyphId, 51 | /// For normal glyphs, scales from font units (integer) to render coordinates. 52 | /// If pixmap is Some, then scales from pixmap pixel to final pixel 53 | pub scale: f32, 54 | /// Relative position from the layout's origin point. 55 | /// 56 | /// If pixmap is Some, this is the top-left corner. 57 | /// For normal glyphs, this is the bottom-left (not a bbox) 58 | pub position: Point, 59 | pub font: &'a FontMapped, 60 | pub color: Color, 61 | pub pixmap: Option, 62 | } 63 | 64 | fn layout_font<'a>( 65 | font: &'a FontMapped, 66 | size_pt: f32, 67 | runtime: &'a Runtime, 68 | rgba: Color, 69 | text: &str, 70 | markup: bool, 71 | ) -> (Vec>, Point) { 72 | let scale = font.scale_from_pt(size_pt); 73 | let mut xpos = 0.0f32; 74 | let mut xmax = 0.0f32; 75 | let mut ypos = scale * (font.as_ref().line_gap() + font.as_ref().ascender()) as f32; 76 | let line_height = ypos - scale * font.as_ref().descender() as f32; 77 | let mut prev = None; 78 | let mut stack = Vec::new(); 79 | let mut skip = 0; 80 | if false { 81 | stack.push((font, rgba)); 82 | } 83 | 84 | let to_draw: Vec<_> = text 85 | .char_indices() 86 | .filter_map(|(i, mut c)| { 87 | if skip > i { 88 | return None; 89 | } 90 | if c == '\n' { 91 | xmax = xmax.max(xpos); 92 | xpos = 0.0; 93 | ypos += line_height as f32; 94 | return None; 95 | } 96 | if c == '\t' { 97 | c = ' '; 98 | } 99 | let mut fid = stack.last().map_or(font, |v| v.0); 100 | let color = stack.last().map_or(rgba, |v| v.1); 101 | if markup && c == '<' { 102 | if let Some(eot) = text[i..].find('>') { 103 | let tag = &text[i..][..eot][1..]; 104 | skip = i + eot + 1; 105 | if tag.starts_with('/') { 106 | stack.pop(); 107 | } else { 108 | let mut color = color; 109 | for kv in tag.split(' ') { 110 | if kv.starts_with("color='") || kv.starts_with("color=\"") { 111 | let v = kv[7..].get(..kv.len() - 8); 112 | color = Formatting::parse_rgba(v, None).unwrap_or(color); 113 | } else if kv.starts_with("color=") { 114 | color = 115 | Formatting::parse_rgba(Some(&kv[6..]), None).unwrap_or(color); 116 | } else if kv.starts_with("font='") || kv.starts_with("font=\"") { 117 | let v = kv[6..].get(..kv.len() - 7); 118 | for font in &runtime.fonts { 119 | if v == Some(font.name.as_str()) { 120 | fid = font; 121 | break; 122 | } 123 | } 124 | } 125 | } 126 | stack.push((fid, color)); 127 | } 128 | return None; 129 | } 130 | } 131 | let mut id = GlyphId(0); 132 | if markup && c == '&' { 133 | if let Some(eot) = text[i..].find(';') { 134 | let tag = &text[i..][..eot][1..]; 135 | skip = i + eot + 1; 136 | if tag.starts_with("#0x") || tag.starts_with("#0X") { 137 | match u32::from_str_radix(&tag[3..], 16) 138 | .ok() 139 | .and_then(char::from_u32) 140 | { 141 | Some(nc) => c = nc, 142 | None => return None, 143 | } 144 | } else if tag.starts_with('#') { 145 | match tag[1..].parse().ok().and_then(char::from_u32) { 146 | Some(nc) => c = nc, 147 | None => return None, 148 | } 149 | } else if tag.starts_with('@') { 150 | if let Ok(gi) = tag[1..].parse::() { 151 | if gi > 0 && gi < fid.as_ref().number_of_glyphs() { 152 | id.0 = gi; 153 | } else { 154 | return None; 155 | } 156 | } else { 157 | return None; 158 | } 159 | } else { 160 | match tag { 161 | "amp" => c = '&', 162 | "lt" => c = '<', 163 | "gt" => c = '>', 164 | _ => return None, 165 | } 166 | } 167 | } 168 | } 169 | if id.0 == 0 { 170 | id = fid.as_ref().glyph_index(c).unwrap_or_default(); 171 | } 172 | if id.0 != 0 { 173 | if let Some(prev) = prev { 174 | let tables = fid.as_ref().tables(); 175 | let offset = tables 176 | .kern 177 | .map(|t| t.subtables) 178 | .into_iter() 179 | .flatten() 180 | .filter(|st| st.horizontal && !st.variable) 181 | .filter_map(|st| st.glyphs_kerning(prev, id)) 182 | .fold(0, |a, b| a + b); 183 | let offset = tables 184 | .kerx 185 | .map(|t| t.subtables) 186 | .into_iter() 187 | .flatten() 188 | .filter(|st| st.horizontal && !st.variable) 189 | .filter_map(|st| st.glyphs_kerning(prev, id)) 190 | .fold(offset, |a, b| a + b); 191 | 192 | xpos += offset as f32 * scale; 193 | } 194 | prev = Some(id); 195 | } else { 196 | let mut i = runtime.fonts.iter(); 197 | loop { 198 | let font = match i.next() { 199 | Some(font) => font, 200 | None => { 201 | info!("Cannot find font for '{}'", c); 202 | return None; 203 | } 204 | }; 205 | if let Some(gid) = font.as_ref().glyph_index(c) { 206 | id = gid; 207 | fid = font; 208 | break; 209 | } 210 | } 211 | prev = None; 212 | } 213 | let position = Point { x: xpos, y: ypos }; 214 | let scale = fid.scale_from_pt(size_pt); 215 | let w = fid.as_ref().glyph_hor_advance(id).unwrap_or(0); 216 | xpos += w as f32 * scale; 217 | Some(CGlyph { 218 | id, 219 | position, 220 | scale, 221 | font: fid, 222 | color, 223 | pixmap: None, 224 | }) 225 | }) 226 | .collect(); 227 | 228 | let text_size = Point { 229 | x: xpos.max(xmax) as f32, 230 | y: ypos - scale * font.as_ref().descender() as f32, 231 | }; 232 | 233 | (to_draw, text_size) 234 | } 235 | 236 | /// Determine the bounding box of this series of glyphs 237 | /// 238 | /// Output coordinates use the render scale and position as the origin. 239 | fn bounding_box(to_draw: &mut [CGlyph], g_scale: f32, stroke: f32) -> (Point, Point) { 240 | let mut tl = Point { 241 | x: f32::MAX, 242 | y: f32::MAX, 243 | }; 244 | let mut br = Point { 245 | x: f32::MIN, 246 | y: f32::MIN, 247 | }; 248 | for &mut CGlyph { 249 | id, 250 | ref mut scale, 251 | ref mut position, 252 | font, 253 | ref mut pixmap, 254 | .. 255 | } in to_draw 256 | { 257 | if let Some(gbox) = font.as_ref().glyph_bounding_box(id) { 258 | let mut g_tl = Point { 259 | x: gbox.x_min as f32, 260 | y: -gbox.y_max as f32, 261 | }; 262 | let mut g_br = Point { 263 | x: gbox.x_max as f32, 264 | y: -gbox.y_min as f32, 265 | }; 266 | g_tl.scale(*scale); 267 | g_br.scale(*scale); 268 | g_tl += *position; 269 | g_br += *position; 270 | tl.x = tl.x.min(g_tl.x - stroke); 271 | tl.y = tl.y.min(g_tl.y - stroke); 272 | br.x = br.x.max(g_br.x + stroke); 273 | br.y = br.y.max(g_br.y + stroke); 274 | continue; 275 | } 276 | 277 | let target_ppem = *scale * font.as_ref().units_per_em() as f32 * g_scale; 278 | let target_h = *scale * font.as_ref().height() as f32 * g_scale; 279 | 280 | position.y -= font.as_ref().ascender() as f32 * *scale; 281 | if let Some(raster_img) = font.as_ref().glyph_raster_image(id, target_ppem as u16) { 282 | if let Some(img) = OwnedImage::from_data(raster_img.data, target_h as u32, false) { 283 | *pixmap = Some(img); 284 | 285 | *scale = target_ppem / raster_img.pixels_per_em as f32 / g_scale; 286 | 287 | position.x += raster_img.x as f32 * *scale; 288 | position.y += raster_img.y as f32 * *scale; 289 | } 290 | } 291 | if pixmap.is_none() { 292 | if let Some(svg) = font.as_ref().glyph_svg_image(id) { 293 | *pixmap = OwnedImage::from_svg(svg.data, target_h as u32); 294 | *scale = 1.; 295 | } 296 | } 297 | if let Some(img) = pixmap.as_ref() { 298 | let size = Point { 299 | x: img.pixmap.width() as f32 / g_scale, 300 | y: img.pixmap.height() as f32 / g_scale, 301 | }; 302 | let g_br = *position + size; 303 | 304 | tl.x = tl.x.min(position.x); 305 | tl.y = tl.y.min(position.y); 306 | br.x = br.x.max(g_br.x); 307 | br.y = br.y.max(g_br.y); 308 | } 309 | } 310 | 311 | (tl, br) 312 | } 313 | 314 | fn draw_font_with( 315 | target: &mut T, 316 | xform: Transform, 317 | to_draw: &[CGlyph], 318 | mut draw: impl FnMut(&mut T, &tiny_skia::Path, Color), 319 | mut draw_img: impl FnMut(&mut T, Transform, &OwnedImage), 320 | ) { 321 | for &CGlyph { 322 | id, 323 | scale, 324 | position, 325 | font, 326 | color, 327 | ref pixmap, 328 | } in to_draw 329 | { 330 | if let Some(img) = pixmap.as_ref() { 331 | let xform = xform.pre_translate(position.x, position.y); 332 | let xform = xform.pre_scale(scale, scale); 333 | draw_img(target, xform, img); 334 | continue; 335 | } 336 | 337 | struct Draw(tiny_skia::PathBuilder); 338 | let mut path = Draw(tiny_skia::PathBuilder::new()); 339 | impl ttf_parser::OutlineBuilder for Draw { 340 | fn move_to(&mut self, x: f32, y: f32) { 341 | self.0.move_to(x, -y); 342 | } 343 | fn line_to(&mut self, x: f32, y: f32) { 344 | self.0.line_to(x, -y); 345 | } 346 | fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { 347 | self.0.quad_to(x1, -y1, x, -y); 348 | } 349 | fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { 350 | self.0.cubic_to(x1, -y1, x2, -y2, x, -y); 351 | } 352 | fn close(&mut self) { 353 | self.0.close(); 354 | } 355 | } 356 | if let Some(_bounds) = font.as_ref().outline_glyph(id, &mut path) { 357 | let xform = xform.pre_translate(position.x, position.y); 358 | let xform = xform.pre_scale(scale, scale); 359 | if let Some(path) = path.0.finish().and_then(|p| p.transform(xform)) { 360 | draw(target, &path, color); 361 | } 362 | continue; 363 | } 364 | } 365 | } 366 | 367 | /// High quality pixmap scaling - used when a resize may happen 368 | static HQ_PIXMAP_PAINT: tiny_skia::PixmapPaint = tiny_skia::PixmapPaint { 369 | opacity: 1.0, 370 | blend_mode: tiny_skia::BlendMode::SourceOver, 371 | quality: tiny_skia::FilterQuality::Bicubic, 372 | }; 373 | 374 | const SUBPIXEL_KEYS: f32 = 8.0; 375 | fn get_subpixel_key(x: f32) -> u8 { 376 | let frac = x - x.floor(); 377 | let idx = frac * SUBPIXEL_KEYS; 378 | idx.floor() as u8 379 | } 380 | 381 | /// Align the given point to the pixel grid, returning true if the change would be unnoticeable 382 | fn align_nearby_grid(Point { x, y }: &mut Point) -> bool { 383 | let margin = 1.0 / SUBPIXEL_KEYS; 384 | let xp = *x; 385 | let yp = *y; 386 | let xr = xp.round(); 387 | let yr = yp.round(); 388 | *x = xr; 389 | *y = yr; 390 | let dx = xp - xr; 391 | let dy = yp - yr; 392 | (dx.abs() <= margin) && (dy.abs() <= margin) 393 | } 394 | 395 | #[derive(Eq, Hash, PartialEq, Debug)] 396 | pub struct RenderKey { 397 | x_offset_subpix: u8, 398 | y_offset_subpix: u8, 399 | 400 | font: UID, 401 | font_size_millipt: u32, 402 | font_color: u32, 403 | text_stroke: u32, 404 | text_stroke_size_milli: u32, 405 | 406 | text: Box, 407 | } 408 | 409 | #[derive(Debug)] 410 | pub struct TextImage { 411 | /// Declared size of the text, in pixels. 412 | /// 413 | /// This controls the movement of render_pos, and is only vaguely related to the size of the 414 | /// bounding box of the text. 415 | text_size: Point, 416 | 417 | /// Clip width used while rendering, in pixels. 418 | /// 419 | /// If this is less than text_size.x, then the pixmap should be considered to be clipped. 420 | clip_w_px: f32, 421 | 422 | /// Pixel distance from the initial render_pos to the actual pixmap origin. 423 | /// 424 | /// This plus pixmap.(width, height) forms the actual bounding box for the text. 425 | origin_offset: Point, 426 | 427 | pixmap: Arc, 428 | pub last_used: Instant, 429 | } 430 | 431 | fn to_color_u32(color: Color) -> u32 { 432 | let c = color.to_color_u8(); 433 | u32::from_ne_bytes([c.red(), c.green(), c.blue(), c.alpha()]) 434 | } 435 | 436 | impl RenderKey { 437 | fn new(ctx: &Render, text: &str) -> Self { 438 | let pixel_x = ctx.render_pos.x * ctx.scale; 439 | let pixel_y = ctx.render_pos.y * ctx.scale; 440 | let xi = get_subpixel_key(pixel_x); 441 | let yi = get_subpixel_key(pixel_y); 442 | 443 | let text_stroke_size_milli = ctx.text_stroke.map_or(0, |_| { 444 | (ctx.text_stroke_size.unwrap_or(1.0) * ctx.scale * 1000.0).round() as u32 445 | }); 446 | RenderKey { 447 | x_offset_subpix: xi, 448 | y_offset_subpix: yi, 449 | font: ctx.font.uid, 450 | font_size_millipt: (ctx.scale * ctx.font_size * 1000.0).round() as u32, 451 | font_color: to_color_u32(ctx.font_color), 452 | 453 | text_stroke: ctx.text_stroke.map_or(0, to_color_u32), 454 | text_stroke_size_milli, 455 | 456 | text: text.into(), 457 | } 458 | } 459 | } 460 | 461 | pub fn render_font_item(ctx: &mut Render, text: &str, markup: bool) { 462 | if text.is_empty() { 463 | return; 464 | } 465 | 466 | let scale = ctx.scale; 467 | let mut render_pos = ctx.render_pos; 468 | 469 | let clip_w = ctx.render_extents.1.x - ctx.render_pos.x; 470 | let clip_h = ctx.render_extents.1.y - ctx.render_extents.0.y; 471 | let clip_w_px = clip_w * scale; 472 | 473 | let key = RenderKey::new(ctx, text); 474 | 475 | /* try */ 476 | match (|| { 477 | let ti = ctx.cache.text.get_mut(&key)?; 478 | let mut text_size = ti.text_size; 479 | 480 | let mut add_clip = false; 481 | 482 | if text_size.x > ti.clip_w_px { 483 | // the cached key was clipped 484 | if ti.clip_w_px > clip_w_px + 1.0 { 485 | // it was clipped too large 486 | add_clip = true; 487 | } else if clip_w_px > ti.clip_w_px + 1.0 { 488 | // the saved pixmap is too small, re-render 489 | return None; 490 | } 491 | // else the clip is close enough 492 | } else if text_size.x > clip_w_px + 1.0 { 493 | // we need to clip it 494 | add_clip = true; 495 | } 496 | 497 | text_size.scale(1. / scale); 498 | 499 | match ctx.align.vert { 500 | Some(f) if !ctx.render_flex => { 501 | let extra = clip_h - text_size.y; 502 | if extra >= 0.0 { 503 | render_pos.y += extra * f; 504 | ctx.render_pos.y += extra * f; 505 | } 506 | } 507 | _ => {} 508 | } 509 | 510 | let mut pixel_pos = ctx.render_pos; 511 | pixel_pos.scale(scale); 512 | 513 | let mut pixmap_tl = pixel_pos - ti.origin_offset; 514 | if !align_nearby_grid(&mut pixmap_tl) { 515 | return None; 516 | } 517 | 518 | let img = ti.pixmap.clone(); 519 | ti.last_used = Instant::now(); 520 | ctx.render_pos += text_size; 521 | if add_clip { 522 | let r = pixmap_tl.x + clip_w_px; 523 | let crop = [0., 0., r, f32::MAX]; 524 | ctx.queue.push_image_clip(pixmap_tl, img, crop); 525 | } else { 526 | ctx.queue.push_image(pixmap_tl, img); 527 | } 528 | 529 | Some(()) 530 | })() { 531 | Some(()) => return, 532 | None => {} 533 | } 534 | 535 | let (mut to_draw, mut text_size) = layout_font( 536 | ctx.font, 537 | ctx.font_size, 538 | &ctx.runtime, 539 | ctx.font_color, 540 | &text, 541 | markup, 542 | ); 543 | 544 | if text_size.x > clip_w { 545 | to_draw.retain(|glyph| glyph.position.x < clip_w); 546 | } 547 | 548 | ctx.render_pos += text_size; 549 | 550 | if !ctx.render_flex { 551 | match ctx.align.vert { 552 | Some(f) => { 553 | let extra = clip_h - text_size.y; 554 | if extra >= 0.0 { 555 | render_pos.y += extra * f; 556 | ctx.render_pos.y += extra * f; 557 | } 558 | } 559 | _ => {} 560 | } 561 | } 562 | 563 | if to_draw.is_empty() || ctx.bounds_only { 564 | return; 565 | } 566 | 567 | let stroke_width = if ctx.text_stroke.is_some() { 568 | ctx.text_stroke_size.unwrap_or(1.0) 569 | } else { 570 | 0.0 571 | }; 572 | 573 | // bounding box relative to render_pos 574 | let bbox = bounding_box(&mut to_draw, scale, 1.0 + stroke_width); 575 | 576 | if bbox.0.x >= bbox.1.x || bbox.0.y >= bbox.1.y { 577 | // empty bounding box 578 | return; 579 | } 580 | 581 | // our pixmap location, in render coordinates, not yet aligned to the pixel grid 582 | let bbox_tl = bbox.0 + render_pos; 583 | let bbox_br = bbox.1 + render_pos; 584 | 585 | // Expand the bbox to full pixels 586 | let pixel_l = (bbox_tl.x * scale).floor(); 587 | let pixel_t = (bbox_tl.y * scale).floor(); 588 | let pixel_r = (bbox_br.x * scale).ceil(); 589 | let pixel_b = (bbox_br.y * scale).ceil(); 590 | 591 | // pixmap location, in pixel coordinates 592 | let pixmap_tl = Point { 593 | x: pixel_l, 594 | y: pixel_t, 595 | }; 596 | let xsize = pixel_r - pixel_l; 597 | let ysize = pixel_b - pixel_t; 598 | 599 | let origin_offset = Point { 600 | x: render_pos.x * scale - pixel_l, 601 | y: render_pos.y * scale - pixel_t, 602 | }; 603 | 604 | // transform from render_pos-relative to our-pixmap-pixel 605 | let render_xform = tiny_skia::Transform { 606 | sx: scale, 607 | sy: scale, 608 | tx: origin_offset.x, 609 | ty: origin_offset.y, 610 | ..tiny_skia::Transform::identity() 611 | }; 612 | 613 | let mut pixmap = match tiny_skia::Pixmap::new(xsize as u32, ysize as u32) { 614 | Some(pixmap) => pixmap, 615 | None => { 616 | log::debug!("Not rendering \"{text}\" ({xsize}, {ysize})"); 617 | return; 618 | } 619 | }; 620 | 621 | if let Some(rgba) = ctx.text_stroke { 622 | let stroke_paint = tiny_skia::Paint { 623 | shader: tiny_skia::Shader::SolidColor(rgba), 624 | anti_alias: true, 625 | ..tiny_skia::Paint::default() 626 | }; 627 | let stroke = tiny_skia::Stroke { 628 | width: stroke_width, 629 | ..Default::default() 630 | }; 631 | 632 | draw_font_with( 633 | &mut pixmap, 634 | render_xform, 635 | &to_draw, 636 | |canvas, path, color| { 637 | canvas.stroke_path(&path, &stroke_paint, &stroke, Transform::identity(), None); 638 | let paint = tiny_skia::Paint { 639 | shader: tiny_skia::Shader::SolidColor(color), 640 | anti_alias: true, 641 | ..tiny_skia::Paint::default() 642 | }; 643 | canvas.fill_path( 644 | &path, 645 | &paint, 646 | tiny_skia::FillRule::EvenOdd, 647 | Transform::identity(), 648 | None, 649 | ); 650 | }, 651 | |canvas, xform, img| { 652 | canvas.draw_pixmap(0, 0, img.as_ref(), &HQ_PIXMAP_PAINT, xform, None); 653 | }, 654 | ); 655 | } else { 656 | draw_font_with( 657 | &mut pixmap, 658 | render_xform, 659 | &to_draw, 660 | |canvas, path, color| { 661 | let paint = tiny_skia::Paint { 662 | shader: tiny_skia::Shader::SolidColor(color), 663 | anti_alias: true, 664 | ..tiny_skia::Paint::default() 665 | }; 666 | canvas.fill_path( 667 | &path, 668 | &paint, 669 | tiny_skia::FillRule::EvenOdd, 670 | Transform::identity(), 671 | None, 672 | ); 673 | }, 674 | |canvas, xform, img| { 675 | canvas.draw_pixmap(0, 0, img.as_ref(), &HQ_PIXMAP_PAINT, xform, None); 676 | }, 677 | ); 678 | } 679 | 680 | let pixmap = Arc::new(pixmap); 681 | 682 | ctx.queue.push_image(pixmap_tl, pixmap.clone()); 683 | 684 | text_size.scale(scale); 685 | ctx.cache.text.insert( 686 | key, 687 | TextImage { 688 | text_size, 689 | clip_w_px, 690 | origin_offset, 691 | pixmap, 692 | last_used: Instant::now(), 693 | }, 694 | ); 695 | } 696 | -------------------------------------------------------------------------------- /src/icon.rs: -------------------------------------------------------------------------------- 1 | use crate::render::Render; 2 | use std::{ 3 | fs::{self, File}, 4 | io, 5 | path::{Component, PathBuf}, 6 | sync::Arc, 7 | }; 8 | use tiny_skia::Transform; 9 | 10 | #[derive(Debug, Clone)] 11 | pub struct OwnedImage { 12 | pub pixmap: Arc, 13 | } 14 | 15 | impl OwnedImage { 16 | pub fn as_ref(&self) -> tiny_skia::PixmapRef { 17 | tiny_skia::Pixmap::as_ref(&self.pixmap) 18 | } 19 | 20 | pub fn from_file(mut file: R, tsize: u32, rescale: bool) -> Option { 21 | let mut buf = Vec::new(); 22 | file.read_to_end(&mut buf).ok()?; 23 | Self::from_data(&buf, tsize, rescale) 24 | } 25 | 26 | pub fn from_data(buf: &[u8], tsize: u32, rescale: bool) -> Option { 27 | Self::from_png(buf) 28 | .map(|img| { 29 | if rescale { 30 | img.rescale_height(tsize) 31 | } else { 32 | img 33 | } 34 | }) 35 | .or_else(|| Self::from_svg(buf, tsize)) 36 | } 37 | 38 | pub fn from_png(data: &[u8]) -> Option { 39 | let mut png = png::Decoder::new(std::io::Cursor::new(data)); 40 | png.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); 41 | let mut png = png.read_info().ok()?; 42 | let color = png.output_color_type().0; 43 | let mut image = vec![0; png.output_buffer_size()]; 44 | png.next_frame(&mut image).ok()?; 45 | 46 | let info = png.info(); 47 | let mut pixmap = tiny_skia::Pixmap::new(info.width as u32, info.height as u32)?; 48 | let step = match color { 49 | png::ColorType::Grayscale => 1, 50 | png::ColorType::GrayscaleAlpha => 2, 51 | png::ColorType::Rgb => 3, 52 | png::ColorType::Rgba => 4, 53 | _ => unreachable!(), 54 | }; 55 | for (src, pixel) in image.chunks(step).zip(pixmap.pixels_mut()) { 56 | let c = match src.len() { 57 | 1 => tiny_skia::ColorU8::from_rgba(src[0], src[0], src[0], 255), 58 | 2 => tiny_skia::ColorU8::from_rgba(src[0], src[0], src[0], src[1]), 59 | 3 => tiny_skia::ColorU8::from_rgba(src[0], src[1], src[2], 255), 60 | 4 => tiny_skia::ColorU8::from_rgba(src[0], src[1], src[2], src[3]), 61 | _ => break, 62 | }; 63 | *pixel = c.premultiply(); 64 | } 65 | Some(Self { 66 | pixmap: Arc::new(pixmap), 67 | }) 68 | } 69 | 70 | pub fn rescale_height(self, height: u32) -> Self { 71 | if self.pixmap.height() == height { 72 | return self; 73 | } 74 | let scale = height as f32 / self.pixmap.height() as f32; 75 | let xform = Transform::from_scale(scale, scale); 76 | let px_width = (self.pixmap.width() as f32 * scale).ceil() as u32; 77 | let mut pixmap = tiny_skia::Pixmap::new(px_width, height).unwrap(); 78 | 79 | pixmap.draw_pixmap( 80 | 0, 81 | 0, 82 | self.as_ref(), 83 | &tiny_skia::PixmapPaint { 84 | opacity: 1.0, 85 | blend_mode: tiny_skia::BlendMode::Source, 86 | quality: tiny_skia::FilterQuality::Bicubic, 87 | }, 88 | xform, 89 | None, 90 | ); 91 | 92 | Self { 93 | pixmap: Arc::new(pixmap), 94 | } 95 | } 96 | 97 | pub fn from_svg(data: &[u8], height: u32) -> Option { 98 | let tree = resvg::usvg::Tree::from_data(data, &Default::default()).ok()?; 99 | let svg_width = tree.size().width(); 100 | let svg_height = tree.size().height(); 101 | let scale = height as f32 / svg_height; 102 | let width = (svg_width * scale).ceil() as u32; 103 | let mut pixmap = tiny_skia::Pixmap::new(width, height)?; 104 | resvg::render( 105 | &tree, 106 | tiny_skia::Transform::from_scale(scale, scale), 107 | &mut pixmap.as_mut(), 108 | ); 109 | Some(Self { 110 | pixmap: Arc::new(pixmap), 111 | }) 112 | } 113 | } 114 | 115 | fn open_icon(xdg: &xdg::BaseDirectories, name: &str, target_size: u32) -> io::Result { 116 | if name.contains('/') { 117 | match File::open(&name) { 118 | Ok(file) => return Ok(file), 119 | _ => {} 120 | } 121 | let mut path = PathBuf::from(name.to_owned()); 122 | path.as_mut_os_string().push(".svg"); 123 | match File::open(&path) { 124 | Ok(file) => return Ok(file), 125 | _ => {} 126 | } 127 | path.set_extension("svg"); 128 | match File::open(&path) { 129 | Ok(file) => return Ok(file), 130 | _ => {} 131 | } 132 | return Err(io::ErrorKind::NotFound.into()); 133 | } 134 | 135 | // return paths in order from highest to lowest priority, unlike how the xdg crate does it 136 | // (sadly that crate doesn't support DoubleEndedIterator yet) 137 | let find_data = |path: &str| { 138 | let dirs: Vec<_> = xdg.find_data_files(path).collect(); 139 | dirs.into_iter().rev() 140 | }; 141 | 142 | let f = |mut path: PathBuf| { 143 | path.push(name); 144 | // We can't use set_extension here because of icon names like "org.atheme.audacious" which 145 | // would turn into "org.atheme.svg" instead of "org.atheme.audacious.svg" 146 | path.as_mut_os_string().push(".svg"); 147 | match File::open(&path) { 148 | Ok(f) => return Some(f), 149 | Err(_) => {} 150 | } 151 | path.set_extension("png"); 152 | match File::open(&path) { 153 | Ok(f) => return Some(f), 154 | Err(_) => {} 155 | } 156 | None 157 | }; 158 | 159 | for path in find_data("pixmaps") { 160 | match f(path) { 161 | Some(rv) => return Ok(rv), 162 | None => {} 163 | } 164 | } 165 | 166 | // TODO take a theme (instead of "hicolor") as an argument 167 | for path in find_data("icons/hicolor") { 168 | match iter_icons(&path, target_size, f)? { 169 | Some(rv) => return Ok(rv), 170 | None => {} 171 | } 172 | } 173 | Err(io::ErrorKind::NotFound.into()) 174 | } 175 | 176 | fn iter_icons(base: &PathBuf, target_size: u32, mut f: F) -> io::Result> 177 | where 178 | F: FnMut(PathBuf) -> Option, 179 | { 180 | let mut sorted_dirs = Vec::new(); 181 | 182 | for size_dir in fs::read_dir(base)? { 183 | let cur_rank; 184 | let mut cur_size = 0; 185 | let size_dir = size_dir?; 186 | if !size_dir.file_type()?.is_dir() { 187 | continue; 188 | } 189 | let size_dir = size_dir.path(); 190 | if let Some(Component::Normal(s)) = size_dir.components().last() { 191 | match s.to_str() { 192 | Some("scalable") => { 193 | cur_rank = 4; 194 | } 195 | Some(s) => { 196 | if let Some(size) = s.find('x').and_then(|p| s[..p].parse::().ok()) { 197 | cur_size = size; 198 | if target_size == size { 199 | cur_rank = 5; 200 | } else if target_size > size { 201 | cur_rank = 2; 202 | } else { 203 | cur_rank = 1; 204 | } 205 | } else { 206 | cur_rank = 3; 207 | } 208 | } 209 | None => continue, 210 | } 211 | } else { 212 | continue; 213 | } 214 | sorted_dirs.push((cur_rank, cur_size, size_dir)); 215 | } 216 | sorted_dirs.sort_unstable(); 217 | 218 | for (_, _, size_dir) in sorted_dirs.into_iter().rev() { 219 | for theme_item in fs::read_dir(size_dir)? { 220 | let path = theme_item?.path(); 221 | if let v @ Some(_) = f(path) { 222 | return Ok(v); 223 | } 224 | } 225 | } 226 | Ok(None) 227 | } 228 | 229 | pub fn render(ctx: &mut Render, name: Box) -> Result<(), ()> { 230 | let room = ctx.render_extents.1 - ctx.render_pos; 231 | let xsize = room.x * ctx.scale; 232 | let ysize = room.y * ctx.scale; 233 | 234 | let tsize = ysize.round() as u32; 235 | if f32::min(xsize, ysize) < 1.0 { 236 | return Err(()); 237 | } 238 | 239 | let img = ctx 240 | .cache 241 | .icon 242 | .entry((name, tsize)) 243 | .or_insert_with_key(|(name, _)| { 244 | open_icon(&ctx.runtime.xdg, name, tsize) 245 | .ok() 246 | .and_then(|file| OwnedImage::from_file(file, tsize, true)) 247 | }) 248 | .as_ref() 249 | .ok_or(())? 250 | .pixmap 251 | .clone(); 252 | 253 | // Align the top-left corner to the pixel grid 254 | let mut tl = ctx.render_pos; 255 | tl.scale(ctx.scale); 256 | tl.x = (tl.x - 0.01).ceil(); 257 | tl.y = (tl.y - 0.01).ceil(); 258 | 259 | // Calculate the bottom-right pixel coordinate, then convert it to render space 260 | let mut br = tiny_skia::Point { 261 | x: tl.x + img.width() as f32, 262 | y: tl.y + img.height() as f32, 263 | }; 264 | br.scale(1. / ctx.scale); 265 | ctx.render_pos = br; 266 | 267 | ctx.queue.push_image(tl, img); 268 | 269 | Ok(()) 270 | } 271 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | mod bar; 4 | mod data; 5 | #[cfg(feature = "dbus")] 6 | mod dbus; 7 | mod event; 8 | mod font; 9 | mod icon; 10 | mod item; 11 | #[cfg(feature = "dbus")] 12 | mod mpris; 13 | mod pipewire; 14 | #[cfg(feature = "pulse")] 15 | mod pulse; 16 | mod render; 17 | mod state; 18 | mod sway; 19 | #[cfg(feature = "dbus")] 20 | mod tray; 21 | mod util; 22 | mod wayland; 23 | mod wlr; 24 | 25 | use state::State; 26 | use wayland::WaylandClient; 27 | 28 | fn main() -> Result<(), Box> { 29 | env_logger::Builder::from_env(env_logger::Env::new().default_filter_or("warn")).init(); 30 | 31 | // Avoid producing zombies. We don't need exit status, and can detect end-of-file on pipes to 32 | // handle any respawning required. 33 | unsafe { 34 | libc::signal(libc::SIGCHLD, libc::SIG_IGN); 35 | } 36 | 37 | let rt = tokio::runtime::Builder::new_current_thread() 38 | .enable_all() 39 | .build()?; 40 | 41 | tokio::task::LocalSet::new().block_on(&rt, async move { 42 | let (client, wl_queue) = WaylandClient::new()?; 43 | 44 | let state = State::new(client)?; 45 | 46 | match wayland::run_queue(wl_queue, state).await? {} 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /src/mpris.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | data::{IterationItem, Value}, 3 | dbus::DBus, 4 | state::{NotifierList, Runtime}, 5 | util::{self, Cell}, 6 | }; 7 | use futures_util::{future::RemoteHandle, StreamExt}; 8 | use log::{debug, error, warn}; 9 | use std::{cell::OnceCell, collections::HashMap, convert::TryInto, error::Error, rc::Rc}; 10 | use zbus::{ 11 | fdo::DBusProxy, 12 | names::{BusName, UniqueName}, 13 | zvariant, 14 | }; 15 | use zvariant::{OwnedValue, Value as Variant}; 16 | 17 | // TODO need nonblocking caching 18 | #[zbus::proxy( 19 | interface = "org.mpris.MediaPlayer2.Player", 20 | default_path = "/org/mpris/MediaPlayer2" 21 | )] 22 | trait Player { 23 | /// Next method 24 | fn next(&self) -> zbus::Result<()>; 25 | 26 | /// Pause method 27 | fn pause(&self) -> zbus::Result<()>; 28 | 29 | /// Play method 30 | fn play(&self) -> zbus::Result<()>; 31 | 32 | /// PlayPause method 33 | fn play_pause(&self) -> zbus::Result<()>; 34 | 35 | /// Previous method 36 | fn previous(&self) -> zbus::Result<()>; 37 | 38 | /// Seek method 39 | fn seek(&self, offset: i64) -> zbus::Result<()>; 40 | 41 | /// SetPosition method 42 | fn set_position(&self, trackid: &zvariant::ObjectPath<'_>, position: i64) -> zbus::Result<()>; 43 | 44 | /// Stop method 45 | fn stop(&self) -> zbus::Result<()>; 46 | 47 | /// Seeked signal 48 | #[zbus(signal)] 49 | fn seeked(&self, position: i64) -> zbus::Result<()>; 50 | 51 | /// CanControl property 52 | #[zbus(property)] 53 | fn can_control(&self) -> zbus::Result; 54 | 55 | /// CanGoNext property 56 | #[zbus(property)] 57 | fn can_go_next(&self) -> zbus::Result; 58 | 59 | /// CanGoPrevious property 60 | #[zbus(property)] 61 | fn can_go_previous(&self) -> zbus::Result; 62 | 63 | /// CanPause property 64 | #[zbus(property)] 65 | fn can_pause(&self) -> zbus::Result; 66 | 67 | /// CanPlay property 68 | #[zbus(property)] 69 | fn can_play(&self) -> zbus::Result; 70 | 71 | /// CanSeek property 72 | #[zbus(property)] 73 | fn can_seek(&self) -> zbus::Result; 74 | 75 | /// Metadata property 76 | #[zbus(property)] 77 | fn metadata(&self) -> zbus::Result>; 78 | 79 | /// PlaybackStatus property 80 | #[zbus(property)] 81 | fn playback_status(&self) -> zbus::Result; 82 | 83 | /// Position property 84 | #[zbus(property)] 85 | fn position(&self) -> zbus::Result; 86 | 87 | /// Volume property 88 | #[zbus(property)] 89 | fn volume(&self) -> zbus::Result; 90 | #[zbus(property)] 91 | fn set_volume(&self, value: f64) -> zbus::Result<()>; 92 | } 93 | 94 | #[derive(Debug, Eq, PartialEq, Copy, Clone)] 95 | enum PlayState { 96 | Playing, 97 | Paused, 98 | Stopped, 99 | } 100 | 101 | impl PlayState { 102 | fn parse(s: &str) -> Option { 103 | match s { 104 | "Playing" => Some(Self::Playing), 105 | "Paused" => Some(Self::Paused), 106 | "Stopped" => Some(Self::Stopped), 107 | _ => None, 108 | } 109 | } 110 | } 111 | 112 | #[derive(Debug)] 113 | struct Player { 114 | name_tail: Rc, 115 | proxy: PlayerProxy<'static>, 116 | playing: Option, 117 | meta: HashMap, 118 | #[allow(unused)] // for Drop 119 | update_handle: RemoteHandle<()>, 120 | } 121 | 122 | #[derive(Debug, Default)] 123 | struct MediaPlayer2 { 124 | players: Cell>, 125 | interested: NotifierList, 126 | } 127 | 128 | thread_local! { 129 | static DATA : OnceCell> = Default::default(); 130 | } 131 | 132 | impl MediaPlayer2 { 133 | pub fn new() -> Rc { 134 | let rv = Rc::new(MediaPlayer2::default()); 135 | 136 | let mpris = rv.clone(); 137 | util::spawn("MPRIS setup", async move { 138 | let dbus = DBus::get_session(); 139 | let zbus = dbus.connection().await?; 140 | 141 | let bus = DBusProxy::builder(&zbus) 142 | .cache_properties(zbus::CacheProperties::No) 143 | .build() 144 | .await?; 145 | 146 | let this = mpris.clone(); 147 | let mut names = bus.receive_name_owner_changed().await?; 148 | util::spawn("MPRIS client add/remove watcher", async move { 149 | while let Some(noc) = names.next().await { 150 | let event = noc.args()?; 151 | if !event.name.starts_with("org.mpris.MediaPlayer2.") { 152 | continue; 153 | } 154 | if let Some(old) = &*event.old_owner { 155 | this.players.take_in(|players| { 156 | players.retain(|player| { 157 | if *player.proxy.inner().destination() == *old { 158 | this.interested.notify_data("mpris:remove"); 159 | false 160 | } else { 161 | true 162 | } 163 | }); 164 | }); 165 | } 166 | 167 | if let Some(new) = &*event.new_owner { 168 | util::spawn( 169 | "MPRIS state query", 170 | this.clone() 171 | .initial_query(event.name.to_owned(), Some(new.to_owned())), 172 | ); 173 | } 174 | } 175 | Ok(()) 176 | }); 177 | 178 | let names = bus.list_names().await?; 179 | 180 | for name in names { 181 | if !name.starts_with("org.mpris.MediaPlayer2.") { 182 | continue; 183 | } 184 | let name = name.into_inner(); 185 | 186 | // query them all in parallel 187 | util::spawn("MPRIS state query", mpris.clone().initial_query(name, None)); 188 | } 189 | 190 | Ok(()) 191 | }); 192 | 193 | rv 194 | } 195 | 196 | async fn initial_query( 197 | self: Rc, 198 | bus_name: BusName<'static>, 199 | owner: Option>, 200 | ) -> Result<(), Box> { 201 | let skip = "org.mpris.MediaPlayer2.".len(); 202 | let name_tail: Rc = bus_name[skip..].into(); 203 | let dbus = DBus::get_session(); 204 | let zbus = dbus.connection().await?; 205 | let owner = match owner { 206 | Some(owner) => owner, 207 | None => DBusProxy::builder(&zbus) 208 | .cache_properties(zbus::CacheProperties::No) 209 | .build() 210 | .await? 211 | .get_name_owner(bus_name) 212 | .await? 213 | .into_inner(), 214 | }; 215 | 216 | let proxy = PlayerProxy::builder(&zbus) 217 | .destination(owner.clone())? 218 | .build() 219 | .await?; 220 | 221 | let property_proxy = 222 | zbus::fdo::PropertiesProxy::new(&zbus, owner, "/org/mpris/MediaPlayer2").await?; 223 | 224 | let prop_stream = property_proxy.receive_properties_changed().await?; 225 | let update_handle = util::spawn_handle( 226 | "MPRIS prop-watcher", 227 | self.clone() 228 | .watch_properties(prop_stream, name_tail.clone()), 229 | ); 230 | 231 | let playing = PlayState::parse(&proxy.playback_status().await?); 232 | let meta = proxy.metadata().await?; 233 | 234 | self.interested.notify_data("mpris:add"); 235 | self.players.take_in(|players| { 236 | players.push(Player { 237 | name_tail, 238 | proxy, 239 | playing, 240 | meta, 241 | update_handle, 242 | }); 243 | }); 244 | 245 | Ok(()) 246 | } 247 | 248 | async fn watch_properties( 249 | self: Rc, 250 | mut s: impl StreamExt + Unpin, 251 | name_tail: Rc, 252 | ) -> Result<(), Box> { 253 | while let Some(msg) = s.next().await { 254 | let args = msg.args()?; 255 | self.players.take_in(|players| { 256 | let player = match players 257 | .iter_mut() 258 | .find(|p| Rc::ptr_eq(&p.name_tail, &name_tail)) 259 | { 260 | Some(player) => player, 261 | None => return, 262 | }; 263 | for (&prop, value) in &args.changed_properties { 264 | match prop { 265 | "PlaybackStatus" => { 266 | if let Ok(status) = value.try_into() { 267 | player.playing = PlayState::parse(status); 268 | } 269 | } 270 | "Metadata" => { 271 | if let Variant::Dict(meta) = &*value { 272 | player.meta = meta.try_clone().unwrap().try_into().unwrap(); 273 | } 274 | } 275 | _ => (), 276 | } 277 | } 278 | self.interested.notify_data("mpris:props"); 279 | }); 280 | } 281 | Ok(()) 282 | } 283 | } 284 | 285 | pub fn read_in R, R>( 286 | _name: &str, 287 | target: &str, 288 | key: &str, 289 | rt: &Runtime, 290 | f: F, 291 | ) -> R { 292 | DATA.with(|cell| { 293 | let state = cell.get_or_init(MediaPlayer2::new); 294 | state.interested.add(rt); 295 | 296 | state.players.take_in(|players| { 297 | let player; 298 | let field; 299 | 300 | if !target.is_empty() { 301 | field = key; 302 | player = players.iter().find(|p| &*p.name_tail == target); 303 | } else if let Some(dot) = key.find('.') { 304 | let name = &key[..dot]; 305 | field = &key[dot + 1..]; 306 | player = players.iter().find(|p| &*p.name_tail == name); 307 | } else { 308 | field = key; 309 | // Prefer playing players, then paused, then any 310 | // 311 | player = players 312 | .iter() 313 | .filter(|p| p.playing == Some(PlayState::Playing)) 314 | .chain( 315 | players 316 | .iter() 317 | .filter(|p| p.playing == Some(PlayState::Paused)), 318 | ) 319 | .chain(players.iter()) 320 | .next(); 321 | } 322 | 323 | if field == "state" { 324 | return match player.and_then(|p| p.playing) { 325 | Some(PlayState::Playing) => f(Value::Borrow("Playing")), 326 | Some(PlayState::Paused) => f(Value::Borrow("Paused")), 327 | Some(PlayState::Stopped) => f(Value::Borrow("Stopped")), 328 | None => f(Value::Null), 329 | }; 330 | } 331 | 332 | if let Some(player) = player { 333 | match field { 334 | "player.name" => f(Value::Borrow(&player.name_tail)), 335 | "length" => match player 336 | .meta 337 | .get("mpris:length") 338 | .map(|v| v.downcast_ref::()) 339 | { 340 | Some(Ok(len)) => f(Value::Float(len as f64 / 1_000_000.0)), 341 | _ => f(Value::Null), 342 | }, 343 | _ if field.contains('.') => { 344 | let real_field = field.replace('.', ":"); 345 | let qf = player.meta.get(&*field).map(|v| v.downcast_ref()); 346 | let rf = player.meta.get(&*real_field).map(|v| v.downcast_ref()); 347 | 348 | match (qf, rf) { 349 | (Some(Ok(v)), _) | (_, Some(Ok(v))) => f(Value::Borrow(v)), 350 | _ => f(Value::Null), 351 | } 352 | } 353 | // See http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata for 354 | // a list of valid names 355 | _ => { 356 | let xeasm = format!("xesam:{}", field); 357 | let value = player.meta.get(&xeasm).map(|x| &**x); 358 | match value { 359 | Some(Variant::Str(v)) => f(Value::Borrow(v.as_str())), 360 | Some(Variant::Array(a)) => { 361 | let mut tmp = String::new(); 362 | for e in a.iter() { 363 | if let Variant::Str(s) = e { 364 | tmp.push_str(s); 365 | tmp.push_str(", "); 366 | } 367 | } 368 | tmp.pop(); 369 | tmp.pop(); 370 | f(Value::Owned(tmp)) 371 | } 372 | _ => f(Value::Null), 373 | } 374 | } 375 | } 376 | } else { 377 | debug!("No media players found"); 378 | f(Value::Null) 379 | } 380 | }) 381 | }) 382 | } 383 | 384 | pub fn read_focus_list(rt: &Runtime, mut f: F) { 385 | let players: Vec<_> = DATA.with(|cell| { 386 | let state = cell.get_or_init(MediaPlayer2::new); 387 | state.interested.add(rt); 388 | state.players.take_in(|players| { 389 | players 390 | .iter() 391 | .map(|p| (p.name_tail.clone(), p.playing == Some(PlayState::Playing))) 392 | .collect() 393 | }) 394 | }); 395 | 396 | for (player, playing) in players { 397 | f(playing, IterationItem::MediaPlayer2 { target: player }); 398 | } 399 | } 400 | 401 | pub fn write(_name: &str, target: &str, key: &str, command: Value, _rt: &Runtime) { 402 | DATA.with(|cell| { 403 | let state = cell.get_or_init(MediaPlayer2::new); 404 | state.players.take_in(|players| { 405 | let player; 406 | 407 | if !target.is_empty() { 408 | player = players.iter().find(|p| &*p.name_tail == target); 409 | } else if !key.is_empty() { 410 | player = players.iter().find(|p| &*p.name_tail == key); 411 | } else { 412 | // Prefer playing players, then paused, then any 413 | player = players 414 | .iter() 415 | .filter(|p| p.playing == Some(PlayState::Playing)) 416 | .chain( 417 | players 418 | .iter() 419 | .filter(|p| p.playing == Some(PlayState::Paused)), 420 | ) 421 | .chain(players.iter()) 422 | .next(); 423 | } 424 | 425 | let player = match player { 426 | Some(p) => p, 427 | None => { 428 | warn!("No player found when sending {}", key); 429 | return; 430 | } 431 | }; 432 | 433 | // TODO call/nowait 434 | let dbus = DBus::get_session(); 435 | let command = command.into_text(); 436 | match &*command { 437 | "Next" | "Previous" | "Pause" | "PlayPause" | "Stop" | "Play" => { 438 | dbus.send( 439 | zbus::Message::method("/org/mpris/MediaPlayer2", &*command) 440 | .unwrap() 441 | .destination(player.proxy.inner().destination().clone()) 442 | .unwrap() 443 | .interface("org.mpris.MediaPlayer2.Player") 444 | .unwrap() 445 | .build(&()) 446 | .unwrap(), 447 | ); 448 | } 449 | // TODO seek, volume? 450 | "Raise" | "Quit" => { 451 | dbus.send( 452 | zbus::Message::method("/org/mpris/MediaPlayer2", &*command) 453 | .unwrap() 454 | .destination(player.proxy.inner().destination().clone()) 455 | .unwrap() 456 | .interface("org.mpris.MediaPlayer2") 457 | .unwrap() 458 | .build(&()) 459 | .unwrap(), 460 | ); 461 | } 462 | _ => { 463 | error!("Unknown command {}", command); 464 | } 465 | } 466 | }) 467 | }) 468 | } 469 | -------------------------------------------------------------------------------- /src/pulse.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | data::{IterationItem, Value}, 3 | state::{NotifierList, Runtime}, 4 | util::{self, Cell}, 5 | }; 6 | use libpulse_binding::{ 7 | callbacks::ListResult, 8 | context::{ 9 | self, 10 | introspect::{ 11 | ClientInfo, Introspector, SinkInfo, SinkInputInfo, SourceInfo, SourceOutputInfo, 12 | }, 13 | subscribe::{Facility, Operation}, 14 | Context, 15 | }, 16 | def::DevicePortType, 17 | error::PAErr, 18 | proplist, 19 | volume::{ChannelVolumes, Volume}, 20 | }; 21 | use libpulse_tokio::TokioMain; 22 | use log::{debug, error, info, warn}; 23 | use once_cell::unsync::OnceCell; 24 | use std::rc::Rc; 25 | 26 | /// An error type that prints an error message when created using ? 27 | /// 28 | /// Needed because PAErr doesn't implement std::error::Error 29 | #[derive(Debug)] 30 | pub struct Error; 31 | 32 | impl From for Error { 33 | fn from(e: PAErr) -> Error { 34 | error!("{}", e); 35 | Error 36 | } 37 | } 38 | 39 | impl From<&str> for Error { 40 | fn from(e: &str) -> Error { 41 | error!("{}", e); 42 | Error 43 | } 44 | } 45 | 46 | /// Information on one audio port (source or sink) 47 | #[derive(Debug)] 48 | struct PortInfo { 49 | index: u32, 50 | name: String, 51 | desc: String, 52 | port: String, 53 | volume: ChannelVolumes, 54 | mute: bool, 55 | monitor: bool, 56 | port_type: Option, 57 | } 58 | 59 | /// Information on one playback or recording stream 60 | #[derive(Debug)] 61 | struct WireInfo { 62 | index: u32, 63 | client: Option, 64 | port: u32, 65 | volume: u32, 66 | #[allow(unused)] // not currently queried 67 | mute: bool, 68 | } 69 | 70 | /// A singleton structure that collects introspection data from the pulse server 71 | #[derive(Debug, Default)] 72 | struct PulseData { 73 | context: Cell>, 74 | default_sink: Cell, 75 | default_source: Cell, 76 | sources: Cell>, 77 | sinks: Cell>, 78 | clients: Cell>, 79 | sink_ins: Cell>, 80 | src_outs: Cell>, 81 | interested: NotifierList, 82 | } 83 | 84 | thread_local! { 85 | static DATA : OnceCell> = Default::default(); 86 | } 87 | 88 | impl PulseData { 89 | fn init() -> Rc { 90 | let me = Rc::new(PulseData::default()); 91 | 92 | let rv = me.clone(); 93 | util::spawn_noerr(async move { 94 | match me.mainloop().await { 95 | Ok(()) | Err(Error) => {} 96 | } 97 | }); 98 | 99 | rv 100 | } 101 | 102 | async fn mainloop(self: Rc) -> Result<(), Error> { 103 | let mut main = TokioMain::new(); 104 | 105 | let mut props = proplist::Proplist::new().ok_or("proplist")?; 106 | props 107 | .set_str(proplist::properties::APPLICATION_NAME, "rwaybar") 108 | .unwrap(); 109 | let mut context = Context::new_with_proplist(&main, "rwaybar", &props).ok_or("context")?; 110 | context.connect(None, context::FlagSet::NOFAIL, None)?; 111 | 112 | match main.wait_for_ready(&context).await { 113 | Ok(context::State::Ready) => {} 114 | Ok(c) => { 115 | error!("Pulse context {:?}, not continuing", c); 116 | return Ok(()); 117 | } 118 | Err(_) => { 119 | error!("Pulse mainloop exited while waiting on context, not continuing"); 120 | return Ok(()); 121 | } 122 | } 123 | 124 | // now that the context is ready, subscribe to changes and query the current state 125 | let data = self.clone(); 126 | let inspect = context.introspect(); 127 | context.set_subscribe_callback(Some(Box::new(move |facility, op, idx| { 128 | data.subscribe_cb(&inspect, facility, op, idx); 129 | }))); 130 | 131 | context.subscribe(context::subscribe::InterestMaskSet::ALL, |_| ()); 132 | 133 | let inspect = context.introspect(); 134 | 135 | // hand off the context so it's available to callers 136 | self.context.set(Some(context)); 137 | 138 | let data = self.clone(); 139 | inspect.get_server_info(move |info| { 140 | if let Some(name) = &info.default_sink_name { 141 | data.default_sink.set((**name).to_owned()); 142 | } 143 | if let Some(name) = &info.default_source_name { 144 | data.default_source.set((**name).to_owned()); 145 | } 146 | }); 147 | 148 | let data = self.clone(); 149 | inspect.get_sink_info_list(move |item| { 150 | data.add_sink(item); 151 | }); 152 | 153 | let data = self.clone(); 154 | inspect.get_source_info_list(move |item| { 155 | data.add_source(item); 156 | }); 157 | 158 | let data = self.clone(); 159 | inspect.get_client_info_list(move |item| { 160 | data.add_client(item); 161 | }); 162 | 163 | let data = self.clone(); 164 | inspect.get_sink_input_info_list(move |item| { 165 | data.add_sink_input(item); 166 | }); 167 | 168 | let data = self.clone(); 169 | inspect.get_source_output_info_list(move |item| { 170 | data.add_source_output(item); 171 | }); 172 | 173 | main.run().await; 174 | error!("Pulse mainloop exited"); 175 | Ok(()) 176 | } 177 | 178 | fn subscribe_cb( 179 | self: &Rc, 180 | inspect: &Introspector, 181 | facility: Option, 182 | op: Option, 183 | idx: u32, 184 | ) { 185 | match (facility, op) { 186 | (Some(Facility::Sink), Some(Operation::New)) 187 | | (Some(Facility::Sink), Some(Operation::Changed)) => { 188 | let data = self.clone(); 189 | inspect.get_sink_info_by_index(idx, move |item| { 190 | data.add_sink(item); 191 | }); 192 | } 193 | (Some(Facility::Sink), Some(Operation::Removed)) => { 194 | self.sinks.take_in(|sinks| { 195 | sinks.retain(|info| info.index != idx); 196 | }); 197 | self.interested.notify_data("pulse"); 198 | } 199 | (Some(Facility::Source), Some(Operation::New)) 200 | | (Some(Facility::Source), Some(Operation::Changed)) => { 201 | let data = self.clone(); 202 | inspect.get_source_info_by_index(idx, move |item| { 203 | data.add_source(item); 204 | }); 205 | } 206 | (Some(Facility::Source), Some(Operation::Removed)) => { 207 | self.sources.take_in(|sources| { 208 | sources.retain(|info| info.index != idx); 209 | }); 210 | self.interested.notify_data("pulse"); 211 | } 212 | (Some(Facility::Client), Some(Operation::New)) 213 | | (Some(Facility::Client), Some(Operation::Changed)) => { 214 | let data = self.clone(); 215 | inspect.get_client_info(idx, move |item| { 216 | data.add_client(item); 217 | }); 218 | } 219 | (Some(Facility::Client), Some(Operation::Removed)) => { 220 | self.clients.take_in(|clients| { 221 | clients.retain(|info| info.0 != idx); 222 | }); 223 | self.interested.notify_data("pulse"); 224 | } 225 | (Some(Facility::SinkInput), Some(Operation::New)) 226 | | (Some(Facility::SinkInput), Some(Operation::Changed)) => { 227 | let data = self.clone(); 228 | inspect.get_sink_input_info(idx, move |item| { 229 | data.add_sink_input(item); 230 | }); 231 | } 232 | (Some(Facility::SinkInput), Some(Operation::Removed)) => { 233 | self.sink_ins.take_in(|sink_ins| { 234 | sink_ins.retain(|info| info.index != idx); 235 | }); 236 | self.interested.notify_data("pulse"); 237 | } 238 | (Some(Facility::SourceOutput), Some(Operation::New)) 239 | | (Some(Facility::SourceOutput), Some(Operation::Changed)) => { 240 | let data = self.clone(); 241 | inspect.get_source_output_info(idx, move |item| { 242 | data.add_source_output(item); 243 | }); 244 | } 245 | (Some(Facility::SourceOutput), Some(Operation::Removed)) => { 246 | self.src_outs.take_in(|src_outs| { 247 | src_outs.retain(|info| info.index != idx); 248 | }); 249 | self.interested.notify_data("pulse"); 250 | } 251 | _ => {} 252 | } 253 | } 254 | 255 | fn add_sink(&self, item: ListResult<&SinkInfo>) { 256 | self.interested.notify_data("pulse"); 257 | match item { 258 | ListResult::Item(info) => { 259 | let pi = PortInfo { 260 | index: info.index, 261 | name: info.name.as_deref().unwrap_or("").to_owned(), 262 | desc: info.description.as_deref().unwrap_or("").to_owned(), 263 | port: info 264 | .active_port 265 | .as_ref() 266 | .and_then(|port| port.description.as_deref()) 267 | .unwrap_or("") 268 | .to_owned(), 269 | volume: info.volume, 270 | mute: info.mute, 271 | monitor: false, 272 | port_type: info.active_port.as_ref().map(|port| port.r#type), 273 | }; 274 | self.sinks.take_in(|sinks| { 275 | for info in &mut *sinks { 276 | if info.index == pi.index { 277 | *info = pi; 278 | return; 279 | } 280 | } 281 | sinks.push(pi); 282 | }); 283 | } 284 | ListResult::End => {} 285 | ListResult::Error => debug!("get_sink_info failed"), 286 | } 287 | } 288 | 289 | fn add_source(&self, item: ListResult<&SourceInfo>) { 290 | self.interested.notify_data("pulse"); 291 | match item { 292 | ListResult::Item(info) => { 293 | let pi = PortInfo { 294 | index: info.index, 295 | name: info.name.as_deref().unwrap_or("").to_owned(), 296 | desc: info.description.as_deref().unwrap_or("").to_owned(), 297 | port: info 298 | .active_port 299 | .as_ref() 300 | .and_then(|port| port.description.as_deref()) 301 | .unwrap_or("") 302 | .to_owned(), 303 | volume: info.volume, 304 | mute: info.mute, 305 | monitor: info.monitor_of_sink.is_some(), 306 | port_type: info.active_port.as_ref().map(|port| port.r#type), 307 | }; 308 | self.sources.take_in(|sources| { 309 | for info in &mut *sources { 310 | if info.index == pi.index { 311 | *info = pi; 312 | return; 313 | } 314 | } 315 | sources.push(pi); 316 | }); 317 | } 318 | ListResult::End => {} 319 | ListResult::Error => debug!("get_source_info failed"), 320 | } 321 | } 322 | 323 | fn add_client(&self, item: ListResult<&ClientInfo>) { 324 | self.interested.notify_data("pulse"); 325 | match item { 326 | ListResult::Item(info) => { 327 | self.clients.take_in(|clients| { 328 | let name = info.name.as_deref().unwrap_or("").to_owned(); 329 | for client in &mut *clients { 330 | if client.0 == info.index { 331 | client.1 = name; 332 | return; 333 | } 334 | } 335 | clients.push((info.index, name)); 336 | }); 337 | } 338 | ListResult::End => {} 339 | ListResult::Error => debug!("get_client_info failed"), 340 | } 341 | } 342 | 343 | fn add_sink_input(&self, item: ListResult<&SinkInputInfo>) { 344 | self.interested.notify_data("pulse"); 345 | match item { 346 | ListResult::Item(info) => { 347 | self.sink_ins.take_in(|sink_ins| { 348 | let new = WireInfo { 349 | index: info.index, 350 | client: info.client, 351 | port: info.sink, 352 | mute: info.mute, 353 | volume: info.volume.avg().0, 354 | }; 355 | for client in &mut *sink_ins { 356 | if client.index == info.index { 357 | *client = new; 358 | return; 359 | } 360 | } 361 | sink_ins.push(new); 362 | }); 363 | } 364 | ListResult::End => {} 365 | ListResult::Error => debug!("get_sink_input failed"), 366 | } 367 | } 368 | 369 | fn add_source_output(&self, item: ListResult<&SourceOutputInfo>) { 370 | self.interested.notify_data("pulse"); 371 | match item { 372 | ListResult::Item(info) => { 373 | self.src_outs.take_in(|src_outs| { 374 | let new = WireInfo { 375 | index: info.index, 376 | client: info.client, 377 | port: info.source, 378 | mute: info.mute, 379 | volume: info.volume.avg().0, 380 | }; 381 | for client in &mut *src_outs { 382 | if client.index == info.index { 383 | *client = new; 384 | return; 385 | } 386 | } 387 | src_outs.push(new); 388 | }); 389 | } 390 | ListResult::End => {} 391 | ListResult::Error => debug!("get_source_output failed"), 392 | } 393 | } 394 | 395 | pub fn with_target(&self, target: &str, f: F) -> R 396 | where 397 | F: FnOnce(Option<&mut PortInfo>, bool, &[WireInfo], &[(u32, String)]) -> R, 398 | { 399 | let name; 400 | let is_sink; 401 | let pi_list; 402 | let wi_list; 403 | 404 | match target { 405 | "sink" | "speaker" | "sink:default" => { 406 | name = self.default_sink.take_in(|v| v.clone()); 407 | is_sink = true; 408 | pi_list = &self.sinks; 409 | wi_list = &self.sink_ins; 410 | } 411 | t if t.starts_with("sink:") => { 412 | name = t[5..].to_owned(); 413 | is_sink = true; 414 | pi_list = &self.sinks; 415 | wi_list = &self.sink_ins; 416 | } 417 | "source" | "mic" | "source:default" => { 418 | name = self.default_source.take_in(|v| v.clone()); 419 | is_sink = false; 420 | pi_list = &self.sources; 421 | wi_list = &self.src_outs; 422 | } 423 | t if t.starts_with("source:") => { 424 | name = t[7..].to_owned(); 425 | is_sink = false; 426 | pi_list = &self.sources; 427 | wi_list = &self.src_outs; 428 | } 429 | _ => { 430 | error!( 431 | "Invalid target specification '{}' - should be source: or sink:", 432 | target 433 | ); 434 | name = String::new(); 435 | is_sink = false; 436 | pi_list = &self.sources; 437 | wi_list = &self.src_outs; 438 | } 439 | } 440 | 441 | self.clients.take_in(|clients| { 442 | wi_list.take_in(|wi| { 443 | pi_list.take_in(|infos| { 444 | for info in infos { 445 | if info.name == name { 446 | return f(Some(info), is_sink, &wi, &clients); 447 | } 448 | } 449 | f(None, is_sink, &[], &clients) 450 | }) 451 | }) 452 | }) 453 | } 454 | } 455 | 456 | pub fn read_focus_list(rt: &Runtime, target: &str, mut f: F) { 457 | let mut items = Vec::new(); 458 | let (do_src, do_sink, do_mon) = match target { 459 | "sources" => (true, false, false), 460 | "monitors" => (false, false, true), 461 | "all-sources" => (true, false, true), 462 | "sinks" => (false, true, false), 463 | "" | "all" => (true, true, true), 464 | _ => { 465 | warn!("Invalid target {} for focus-list", target); 466 | return; 467 | } 468 | }; 469 | 470 | DATA.with(|cell| { 471 | let pulse = cell.get_or_init(PulseData::init); 472 | pulse.interested.add(&rt); 473 | 474 | if do_src || do_mon { 475 | let default_source = pulse.default_source.take_in(|v| v.clone()); 476 | pulse.sources.take_in(|srcs| { 477 | for item in srcs { 478 | let is_def = item.name == default_source; 479 | if item.monitor && !do_mon { 480 | continue; 481 | } 482 | if !item.monitor && !do_src { 483 | continue; 484 | } 485 | items.push((is_def, format!("source:{}", item.name).into())); 486 | } 487 | }); 488 | } 489 | 490 | if do_sink { 491 | let default_sink = pulse.default_sink.take_in(|v| v.clone()); 492 | pulse.sinks.take_in(|sinks| { 493 | for item in sinks { 494 | let is_def = item.name == default_sink; 495 | items.push((is_def, format!("sink:{}", item.name).into())); 496 | } 497 | }); 498 | } 499 | }); 500 | 501 | for (def, target) in items { 502 | f(def, IterationItem::Pulse { target }); 503 | } 504 | } 505 | 506 | pub fn read_in R, R>( 507 | cfg_name: &str, 508 | target: &str, 509 | mut key: &str, 510 | rt: &Runtime, 511 | f: F, 512 | ) -> R { 513 | let mut target = target; 514 | if target.is_empty() { 515 | if let Some(pos) = key.rfind('.') { 516 | target = &key[..pos]; 517 | key = &key[pos + 1..]; 518 | } else { 519 | target = "sink"; 520 | } 521 | } 522 | DATA.with(|cell| { 523 | let pulse = cell.get_or_init(PulseData::init); 524 | pulse.interested.add(&rt); 525 | 526 | pulse.with_target(target, |port, _is_sink, wires, clients| { 527 | let port = match port { 528 | Some(port) => port, 529 | None => { 530 | return f(Value::Null); 531 | } 532 | }; 533 | match key { 534 | "tooltip" => { 535 | let mut v = String::new(); 536 | let volume = port.volume.avg(); 537 | v.push_str(volume.print().trim()); 538 | v.push_str(" - "); 539 | v.push_str(&port.port); 540 | v.push_str(" on "); 541 | v.push_str(&port.desc); 542 | v.push_str("\n"); 543 | let p_id = port.index; 544 | 545 | for info in wires { 546 | if info.port == p_id { 547 | let volume = Volume(info.volume); 548 | v.push_str(volume.print().trim()); 549 | v.push_str(" - "); 550 | if let Some(cid) = info.client { 551 | for client in clients { 552 | if client.0 == cid { 553 | v.push_str(&client.1); 554 | break; 555 | } 556 | } 557 | } else { 558 | v.push_str("(no client)"); 559 | } 560 | v.push_str("\n"); 561 | } 562 | } 563 | v.pop(); 564 | f(Value::Owned(v)) 565 | } 566 | "text" | "volume" => { 567 | if key == "text" && port.mute { 568 | f(Value::Borrow("-")) 569 | } else { 570 | let volume = port.volume.avg(); 571 | f(Value::Borrow(volume.print().trim())) 572 | } 573 | } 574 | "type" => { 575 | if let Some(ty) = port.port_type { 576 | f(Value::Owned(format!("{:?}", ty))) 577 | } else { 578 | f(Value::Null) 579 | } 580 | } 581 | "mute" => f(Value::Bool(port.mute)), 582 | _ => { 583 | info!("Unknown key '{}' in '{}'", key, cfg_name); 584 | f(Value::Null) 585 | } 586 | } 587 | }) 588 | }) 589 | } 590 | 591 | pub fn do_write(_name: &str, target: &str, mut key: &str, value: Value, _rt: &Runtime) { 592 | let mut target = target; 593 | if target.is_empty() { 594 | if let Some(pos) = key.rfind('.') { 595 | target = &key[..pos]; 596 | key = &key[pos + 1..]; 597 | } else { 598 | target = "sink"; 599 | } 600 | } 601 | DATA.with(|cell| { 602 | let pulse = cell.get_or_init(PulseData::init); 603 | 604 | pulse.context.take_in_some(|ctx| { 605 | pulse.with_target(target, |port, is_sink, _wires, _clients| { 606 | let port = match port { 607 | Some(port) => port, 608 | None => { 609 | info!("Ignoring write to unknown item '{}'", target); 610 | return; 611 | } 612 | }; 613 | 614 | match key { 615 | "volume" => { 616 | let value = value.into_text(); 617 | let mut amt = &value[..]; 618 | let dir = amt.chars().next(); 619 | if matches!(dir, Some('+') | Some('-')) { 620 | amt = &amt[1..]; 621 | } 622 | if amt.ends_with('%') { 623 | amt = &amt[..amt.len() - 1]; 624 | } 625 | let step = match amt.parse::() { 626 | _ if amt.is_empty() => 1.0, 627 | Ok(v) => v, 628 | Err(e) => { 629 | error!("Cannot parse volume adjustment '{}': {}", value, e); 630 | return; 631 | } 632 | }; 633 | let value = Volume::NORMAL.0 as f64 * step / 100.0; 634 | match dir { 635 | Some('+') => { 636 | port.volume.increase(Volume(value as u32)); 637 | } 638 | Some('-') => { 639 | port.volume.decrease(Volume(value as u32)); 640 | } 641 | _ => { 642 | port.volume.scale(Volume(value as u32)); 643 | } 644 | } 645 | if is_sink { 646 | ctx.introspect().set_sink_volume_by_index( 647 | port.index, 648 | &port.volume, 649 | None, 650 | ); 651 | } else { 652 | ctx.introspect().set_source_volume_by_index( 653 | port.index, 654 | &port.volume, 655 | None, 656 | ); 657 | } 658 | pulse.interested.notify_data("pulse:write-volume"); 659 | } 660 | "mute" => { 661 | let old = port.mute; 662 | let new = match value.parse_bool() { 663 | Some(b) => b, 664 | None => match value.as_str_fast() { 665 | "toggle" => !old, 666 | "on" | "1" => true, 667 | "off" | "0" => false, 668 | _ => { 669 | error!("Invalid mute request '{}'", value); 670 | return; 671 | } 672 | }, 673 | }; 674 | if old == new { 675 | return; 676 | } 677 | port.mute = new; 678 | if is_sink { 679 | ctx.introspect() 680 | .set_sink_mute_by_index(port.index, new, None); 681 | } else { 682 | ctx.introspect() 683 | .set_source_mute_by_index(port.index, new, None); 684 | } 685 | pulse.interested.notify_data("pulse:write-mute"); 686 | } 687 | _ => { 688 | info!("Ignoring write to unknown key '{}'", target); 689 | } 690 | } 691 | }); 692 | }); 693 | }); 694 | } 695 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use futures_util::future::poll_fn; 2 | use log::{debug, error, info, warn}; 3 | use smithay_client_toolkit::shell::WaylandSurface; 4 | use std::{ 5 | cell::RefCell, 6 | collections::HashMap, 7 | error::Error, 8 | iter, 9 | rc::{self, Rc}, 10 | task, 11 | time::Instant, 12 | }; 13 | use wayland_client::{ 14 | protocol::{wl_callback, wl_output::WlOutput}, 15 | Connection, 16 | }; 17 | 18 | use crate::{ 19 | bar::Bar, 20 | data::{EvalContext, IterationItem, Module, Value}, 21 | font::FontMapped, 22 | item::*, 23 | render::Renderer, 24 | util::{spawn, spawn_noerr, Cell}, 25 | wayland::{SurfaceData, WaylandClient}, 26 | }; 27 | 28 | #[derive(Default, Debug, Copy, Clone, Eq, PartialEq)] 29 | pub struct InterestMask(u64); 30 | 31 | impl InterestMask { 32 | pub fn bar_region(&self, region: u64) -> Self { 33 | InterestMask(self.0 * region) 34 | } 35 | } 36 | 37 | thread_local! { 38 | static NOTIFY: NotifierInner = NotifierInner { 39 | waker: Cell::new(None), 40 | state: Cell::new(NotifyState::Idle), 41 | }; 42 | } 43 | 44 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 45 | enum NotifyState { 46 | Idle, 47 | DrawOnly, 48 | NewData(InterestMask), 49 | } 50 | 51 | #[derive(Debug)] 52 | struct NotifierInner { 53 | waker: Cell>, 54 | state: Cell, 55 | } 56 | 57 | impl NotifierInner { 58 | pub fn notify_draw_only(&self) { 59 | if self.state.get() == NotifyState::Idle { 60 | self.state.set(NotifyState::DrawOnly); 61 | self.waker.take().map(|w| w.wake()); 62 | } 63 | } 64 | 65 | fn full_redraw(&self) { 66 | self.waker.take().map(|w| w.wake()); 67 | self.state.set(NotifyState::NewData(InterestMask(!0))); 68 | } 69 | } 70 | 71 | #[derive(Debug, Clone)] 72 | pub struct DrawNotifyHandle {} 73 | 74 | impl DrawNotifyHandle { 75 | pub fn new(_: &Runtime) -> Self { 76 | Self {} 77 | } 78 | 79 | pub fn notify_draw_only(&self) { 80 | NOTIFY.with(|notify| notify.notify_draw_only()); 81 | } 82 | } 83 | 84 | #[derive(Debug, Default)] 85 | pub struct NotifierList { 86 | interest: Cell, 87 | } 88 | 89 | impl NotifierList { 90 | /// Add the currently-rendering bar to this list 91 | /// 92 | /// The next call to notify_data will redraw the bar that was rendering when this was called. 93 | pub fn add(&self, rt: &Runtime) { 94 | self.interest 95 | .set(InterestMask(self.interest.get().0 | rt.interest.get().0)); 96 | } 97 | 98 | /// Add all the items in `other` to this notifier, so they will also be marked dirty when this 99 | /// notifier is used. 100 | pub fn merge(&self, other: &Self) { 101 | self.interest 102 | .set(InterestMask(self.interest.get().0 | other.interest.get().0)); 103 | } 104 | 105 | /// Mark all items in this notifier list as dirty. 106 | /// 107 | /// Future calls to notify_data will do nothing until you add() bars again. 108 | pub fn notify_data(&self, who: &str) { 109 | let mut interest = self.interest.take(); 110 | if interest.0 == 0 { 111 | return; 112 | } 113 | debug!( 114 | "{} triggered refresh on {}", 115 | who, 116 | (0..32) 117 | .filter_map(|i| { 118 | let v = (interest.0 >> (2 * i)) & 3; 119 | if v != 0 { 120 | use std::fmt::Write; 121 | let mut r = String::new(); 122 | for (i, c) in b"BP".iter().enumerate() { 123 | if v & (1 << i) != 0 { 124 | r.push(*c as char) 125 | } 126 | } 127 | write!(r, "{i}").unwrap(); 128 | Some(r) 129 | } else { 130 | None 131 | } 132 | }) 133 | .collect::>() 134 | .join(", ") 135 | ); 136 | NOTIFY.with(|notify| { 137 | match notify.state.get() { 138 | NotifyState::Idle => { 139 | notify.waker.take().map(|w| w.wake()); 140 | } 141 | NotifyState::DrawOnly => { 142 | // already woken, and no added items 143 | } 144 | NotifyState::NewData(mask) => { 145 | interest.0 |= mask.0; 146 | } 147 | } 148 | notify.state.set(NotifyState::NewData(interest)) 149 | }); 150 | } 151 | } 152 | 153 | /// Common state available during rendering operations 154 | #[derive(Debug)] 155 | pub struct Runtime { 156 | pub xdg: xdg::BaseDirectories, 157 | pub fonts: Vec, 158 | pub items: HashMap, Rc>, 159 | pub wayland: WaylandClient, 160 | item_var: Rc, 161 | interest: Cell, 162 | read_depth: Cell, 163 | } 164 | 165 | impl Runtime { 166 | pub fn set_interest_mask(&self, mask: InterestMask) { 167 | self.interest.set(mask); 168 | } 169 | 170 | pub fn get_recursion_handle(&self) -> Option { 171 | let depth = self.read_depth.get(); 172 | if depth > 80 { 173 | None 174 | } else { 175 | self.read_depth.set(depth + 1); 176 | struct LoopRef<'a>(&'a Runtime); 177 | impl<'a> Drop for LoopRef<'a> { 178 | fn drop(&mut self) { 179 | self.0.read_depth.set(self.0.read_depth.get() - 1) 180 | } 181 | } 182 | Some(LoopRef(self)) 183 | } 184 | } 185 | 186 | pub fn eval(&self, expr: &str) -> Result, evalexpr::EvalexprError> { 187 | let expr = evalexpr::build_operator_tree(expr)?; 188 | let mut vars = Vec::new(); 189 | for ident in expr.iter_variable_identifiers() { 190 | if let Some(item) = self.items.get(ident) { 191 | let value = item.data.read_to_owned(ident, "", self); 192 | vars.push((ident, value.into())); 193 | } else { 194 | return Err(evalexpr::EvalexprError::CustomMessage(format!( 195 | "Value {ident} not found" 196 | ))); 197 | } 198 | } 199 | let ctx = EvalContext { rt: &self, vars }; 200 | expr.eval_with_context(&ctx).map(Into::into) 201 | } 202 | 203 | pub fn format<'a>(&'a self, fmt: &'a str) -> Result, strfmt::FmtError> { 204 | if !fmt.contains("{") { 205 | return Ok(Value::Borrow(fmt)); 206 | } 207 | if fmt.starts_with("{") 208 | && fmt.ends_with("}") 209 | && !fmt[1..fmt.len() - 1].contains(&['{', ':'] as &[char]) 210 | { 211 | let q = &fmt[1..fmt.len() - 1]; 212 | if q.starts_with("=") { 213 | return self 214 | .eval(&q[1..]) 215 | .map_err(|e| strfmt::FmtError::KeyError(e.to_string())); 216 | } 217 | let (name, key) = match q.find('.') { 218 | Some(p) => (&q[..p], &q[p + 1..]), 219 | None => (&q[..], ""), 220 | }; 221 | if let Some(item) = self.items.get(name) { 222 | return Ok(item.data.read_to_owned(name, key, self)); 223 | } else { 224 | return Err(strfmt::FmtError::KeyError(name.to_string())); 225 | } 226 | } 227 | 228 | strfmt::strfmt_map(fmt, |mut q| { 229 | if q.key.starts_with("=") { 230 | match self.eval(&q.key[1..]) { 231 | Ok(s) => { 232 | return match Value::from(s) { 233 | Value::Borrow(s) => q.str(s), 234 | Value::Owned(s) => q.str(&s), 235 | Value::Float(f) => q.f64(f), 236 | Value::Bool(true) => q.str("1"), 237 | Value::Bool(false) => q.str("0"), 238 | Value::Null => q.str(""), 239 | } 240 | } 241 | Err(e) => { 242 | return Err(strfmt::FmtError::KeyError(e.to_string())); 243 | } 244 | } 245 | } 246 | let (name, key) = match q.key.find('.') { 247 | Some(p) => (&q.key[..p], &q.key[p + 1..]), 248 | None => (&q.key[..], ""), 249 | }; 250 | match self.items.get(name) { 251 | Some(item) => item.data.read_in(name, key, self, |s| match s { 252 | Value::Borrow(s) => q.str(s), 253 | Value::Owned(s) => q.str(&s), 254 | Value::Float(f) => q.f64(f), 255 | Value::Bool(true) => q.str("1"), 256 | Value::Bool(false) => q.str("0"), 257 | Value::Null => q.str(""), 258 | }), 259 | None => Err(strfmt::FmtError::KeyError(name.to_string())), 260 | } 261 | }) 262 | .map(Value::Owned) 263 | } 264 | 265 | pub fn format_or<'a>(&'a self, fmt: &'a str, context: &str) -> Value<'a> { 266 | match self.format(fmt) { 267 | Ok(v) => v, 268 | Err(e) => { 269 | warn!("Error formatting '{}': {}", context, e); 270 | Value::Null 271 | } 272 | } 273 | } 274 | 275 | pub fn copy_item_var(&self) -> Option { 276 | self.get_item_var().take_in_some(|v| v.clone()) 277 | } 278 | 279 | pub fn get_item_var(&self) -> &Cell> { 280 | match &*self.item_var { 281 | &Item { 282 | data: Module::Item { ref value }, 283 | .. 284 | } => value, 285 | _ => { 286 | panic!("The 'item' variable was not assignable"); 287 | } 288 | } 289 | } 290 | } 291 | 292 | /// The singleton global state object 293 | #[derive(Debug)] 294 | pub struct State { 295 | pub bars: Vec, 296 | bar_config: Vec, 297 | pub renderer: Renderer, 298 | pub runtime: Runtime, 299 | this: rc::Weak>, 300 | } 301 | 302 | impl State { 303 | pub fn new(wayland: WaylandClient) -> Result>, Box> { 304 | log::debug!("State::new"); 305 | 306 | let mut state = Self { 307 | bars: Vec::new(), 308 | bar_config: Vec::new(), 309 | renderer: Renderer::new(), 310 | runtime: Runtime { 311 | xdg: xdg::BaseDirectories::new()?, 312 | fonts: Vec::new(), 313 | items: Default::default(), 314 | item_var: Item::new_current_item(), 315 | interest: Cell::new(InterestMask(0)), 316 | read_depth: Cell::new(0), 317 | wayland, 318 | }, 319 | this: rc::Weak::new(), 320 | }; 321 | 322 | state.load_config(false)?; 323 | 324 | let rv = Rc::new(RefCell::new(state)); 325 | rv.borrow_mut().this = Rc::downgrade(&rv); 326 | 327 | let state = rv.clone(); 328 | spawn_noerr(async move { 329 | loop { 330 | poll_fn(|ctx| { 331 | NOTIFY.with(|notify| { 332 | if notify.state.get() == NotifyState::Idle { 333 | notify.waker.set(Some(ctx.waker().clone())); 334 | task::Poll::Pending 335 | } else { 336 | task::Poll::Ready(()) 337 | } 338 | }) 339 | }) 340 | .await; 341 | let mut state = state.borrow_mut(); 342 | state.draw_now(); 343 | } 344 | }); 345 | 346 | let state = rv.clone(); 347 | spawn("Config reload", async move { 348 | let mut hups = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?; 349 | while let Some(()) = hups.recv().await { 350 | match state.borrow_mut().load_config(true) { 351 | Ok(()) => (), 352 | Err(e) => error!("Config reload failed: {}", e), 353 | } 354 | } 355 | Ok(()) 356 | }); 357 | 358 | Ok(rv) 359 | } 360 | 361 | /// Note: always call from a task, not drectly from dispatch 362 | fn load_config(&mut self, reload: bool) -> Result<(), Box> { 363 | let mut bar_config = Vec::new(); 364 | let mut font_list = Vec::new(); 365 | 366 | let config_path = self 367 | .runtime 368 | .xdg 369 | .find_config_file("rwaybar.toml") 370 | .ok_or("Could not find configuration: create ~/.config/rwaybar.toml")?; 371 | 372 | let cfg = std::fs::read_to_string(config_path)?; 373 | let config: toml::Value = toml::from_str(&cfg)?; 374 | 375 | let cfg = config.as_table().unwrap(); 376 | 377 | let new_items = cfg 378 | .iter() 379 | .filter_map(|(key, value)| match key.as_str() { 380 | "bar" => { 381 | if let Some(bars) = value.as_array() { 382 | bar_config.extend(bars.iter().cloned()); 383 | } else { 384 | bar_config.push(value.clone()); 385 | } 386 | None 387 | } 388 | "fonts" => { 389 | if let Some(list) = value.as_table() { 390 | font_list = list.iter().collect(); 391 | } 392 | None 393 | } 394 | key => { 395 | let key = key.into(); 396 | let value = Rc::new(Item::from_item_list(&key, value)); 397 | Some((key, value)) 398 | } 399 | }) 400 | .collect(); 401 | 402 | if bar_config.is_empty() { 403 | Err("At least one [[bar]] section is required")?; 404 | } 405 | 406 | let mut fonts = Vec::with_capacity(font_list.len()); 407 | for (name, path) in font_list { 408 | match FontMapped::new(name.clone(), path.as_str().unwrap_or("").to_owned().into()) { 409 | Ok(font) => fonts.push(font), 410 | Err(e) => { 411 | error!("Could not load font '{name}' from {path}: {e}"); 412 | } 413 | } 414 | } 415 | 416 | if fonts.is_empty() { 417 | Err("At least one valid font is required in the [fonts] section")?; 418 | } 419 | 420 | debug!("Loading configuration"); 421 | 422 | let mut old_items = std::mem::replace(&mut self.runtime.items, new_items); 423 | self.bar_config = bar_config; 424 | self.runtime.fonts = fonts; 425 | 426 | self.runtime 427 | .items 428 | .insert("item".into(), self.runtime.item_var.clone()); 429 | 430 | for (k, v) in &self.runtime.items { 431 | if let Some(item) = old_items.remove(k) { 432 | v.data.init(k, &self.runtime, Some(&item.data)); 433 | } else { 434 | v.data.init(k, &self.runtime, None); 435 | } 436 | } 437 | NOTIFY.with(|notify| notify.full_redraw()); 438 | 439 | self.bars.clear(); 440 | for output in self.runtime.wayland.output.outputs() { 441 | self.output_ready(&output); 442 | } 443 | if reload { 444 | if self.bars.is_empty() { 445 | error!("No bars matched this outptut configuration. Available outputs:"); 446 | for output in self.runtime.wayland.output.outputs() { 447 | if let Some(oi) = self.runtime.wayland.output.info(&output) { 448 | error!( 449 | " name='{}' description='{}' make='{}' model='{}'", 450 | oi.name.as_deref().unwrap_or_default(), 451 | oi.description.as_deref().unwrap_or_default(), 452 | oi.make, 453 | oi.model 454 | ); 455 | } 456 | } 457 | } 458 | } else { 459 | self.set_data(); 460 | } 461 | 462 | Ok(()) 463 | } 464 | 465 | /// Request a redraw of all surfaces that have been damaged and whose rendering is not 466 | /// throttled. This should be called after damaging a surface in some way unrelated to the 467 | /// items on the surface, such as by receiving a configure or scale event from the compositor. 468 | pub fn request_draw(&mut self) { 469 | NOTIFY.with(|notify| notify.notify_draw_only()); 470 | } 471 | 472 | fn set_data(&mut self) { 473 | // Propagate new_data notifications to all bar dirty fields 474 | let dirty_mask = match NOTIFY.with(|notify| notify.state.replace(NotifyState::Idle)) { 475 | NotifyState::Idle => return, 476 | NotifyState::DrawOnly => return, 477 | NotifyState::NewData(d) => d.0, 478 | }; 479 | 480 | for (i, bar) in (0..31).chain(iter::repeat(31)).zip(&mut self.bars) { 481 | let mask = (dirty_mask >> (2 * i)) & 3; 482 | if mask & 1 != 0 { 483 | SurfaceData::from_wl(bar.ls.wl_surface()).damage_full(); 484 | } 485 | if mask & 2 != 0 { 486 | if let Some(popup) = &bar.popup { 487 | SurfaceData::from_wl(&popup.wl.surf).damage_full(); 488 | } 489 | } 490 | } 491 | } 492 | 493 | pub fn draw_now(&mut self) { 494 | self.set_data(); 495 | 496 | let begin = Instant::now(); 497 | for (i, bar) in (0..31).chain(iter::repeat(31)).zip(&mut self.bars) { 498 | let mask = InterestMask(1 << (2 * i)); 499 | bar.render_with(mask, &mut self.runtime, &mut self.renderer); 500 | } 501 | self.runtime.set_interest_mask(InterestMask(0)); 502 | self.renderer.cache.prune(begin); 503 | self.runtime.wayland.flush(); 504 | let render_time = begin.elapsed().as_nanos(); 505 | log::debug!( 506 | "Frame took {}.{:06} ms", 507 | render_time / 1_000_000, 508 | render_time % 1_000_000 509 | ); 510 | } 511 | 512 | pub fn output_ready(&mut self, output: &WlOutput) { 513 | let data = match self.runtime.wayland.output.info(&output) { 514 | Some(info) => info, 515 | None => return, 516 | }; 517 | info!( 518 | "Output name='{}' description='{}' make='{}' model='{}'", 519 | data.name.as_deref().unwrap_or_default(), 520 | data.description.as_deref().unwrap_or_default(), 521 | data.make, 522 | data.model 523 | ); 524 | for (i, cfg) in self.bar_config.iter().enumerate() { 525 | if let Some(name) = cfg.get("name").and_then(|v| v.as_str()) { 526 | if Some(name) != data.name.as_deref() { 527 | continue; 528 | } 529 | } 530 | if let Some(make) = cfg.get("make").and_then(|v| v.as_str()) { 531 | match regex::Regex::new(make) { 532 | Ok(re) => { 533 | if !re.is_match(&data.make) { 534 | continue; 535 | } 536 | } 537 | Err(e) => { 538 | error!("Ignoring invalid regex in bar.make: {}", e); 539 | } 540 | } 541 | } 542 | if let Some(model) = cfg.get("model").and_then(|v| v.as_str()) { 543 | match regex::Regex::new(model) { 544 | Ok(re) => { 545 | if !re.is_match(&data.model) { 546 | continue; 547 | } 548 | } 549 | Err(e) => { 550 | error!("Ignoring invalid regex in bar.model: {}", e); 551 | } 552 | } 553 | } 554 | if let Some(description) = cfg.get("description").and_then(|v| v.as_str()) { 555 | match regex::Regex::new(description) { 556 | Ok(re) => { 557 | if !re.is_match(data.description.as_deref().unwrap_or_default()) { 558 | continue; 559 | } 560 | } 561 | Err(e) => { 562 | error!("Ignoring invalid regex in bar.description: {}", e); 563 | } 564 | } 565 | } 566 | let mut cfg = cfg.clone(); 567 | let name = data.name.clone().unwrap_or_default(); 568 | self.bars 569 | .retain(|bar| bar.cfg_index != i || &*bar.name != name); 570 | if let Some(table) = cfg.as_table_mut() { 571 | table.insert("name".into(), name.into()); 572 | } 573 | 574 | let bar = Bar::new(&mut self.runtime.wayland, &output, &data, cfg, i); 575 | self.bars.push(bar); 576 | self.runtime.wayland.flush(); 577 | } 578 | } 579 | } 580 | 581 | pub struct OutputsReadyCallback; 582 | 583 | impl wayland_client::Dispatch for State { 584 | fn event( 585 | state: &mut State, 586 | _: &wl_callback::WlCallback, 587 | _: wl_callback::Event, 588 | _: &OutputsReadyCallback, 589 | _: &Connection, 590 | _: &wayland_client::QueueHandle, 591 | ) { 592 | debug!("Done with initial events; checking if config is empty."); 593 | if state.bars.is_empty() { 594 | error!("No bars matched this outptut configuration. Available outputs:"); 595 | for output in state.runtime.wayland.output.outputs() { 596 | if let Some(oi) = state.runtime.wayland.output.info(&output) { 597 | error!( 598 | " name='{}' description='{}' make='{}' model='{}'", 599 | oi.name.as_deref().unwrap_or_default(), 600 | oi.description.as_deref().unwrap_or_default(), 601 | oi.make, 602 | oi.model 603 | ); 604 | } 605 | } 606 | } 607 | } 608 | } 609 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use futures_util::{future::RemoteHandle, FutureExt}; 2 | use log::error; 3 | use std::{ 4 | borrow::Cow, 5 | convert::Infallible, 6 | error::Error, 7 | fmt, fs, 8 | future::Future, 9 | os::unix::io::{AsRawFd, RawFd}, 10 | path::PathBuf, 11 | }; 12 | 13 | pub fn toml_to_string(value: Option<&toml::Value>) -> Option { 14 | value.and_then(|value| { 15 | if let Some(v) = value.as_str() { 16 | Some(v.to_owned()) 17 | } else if let Some(v) = value.as_integer() { 18 | Some(format!("{}", v)) 19 | } else if let Some(v) = value.as_float() { 20 | Some(format!("{}", v)) 21 | } else { 22 | None 23 | } 24 | }) 25 | } 26 | 27 | pub fn toml_to_f64(value: Option<&toml::Value>) -> Option { 28 | value.and_then(|value| { 29 | if let Some(v) = value.as_float() { 30 | Some(v) 31 | } else if let Some(v) = value.as_integer() { 32 | Some(v as f64) 33 | } else { 34 | None 35 | } 36 | }) 37 | } 38 | 39 | #[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] 40 | pub struct ImplDebug(pub T); 41 | 42 | impl fmt::Debug for ImplDebug { 43 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 44 | write!(fmt, "{}", std::any::type_name::()) 45 | } 46 | } 47 | 48 | impl From for ImplDebug { 49 | fn from(t: T) -> Self { 50 | Self(t) 51 | } 52 | } 53 | 54 | impl std::ops::Deref for ImplDebug { 55 | type Target = T; 56 | fn deref(&self) -> &T { 57 | &self.0 58 | } 59 | } 60 | 61 | impl std::ops::DerefMut for ImplDebug { 62 | fn deref_mut(&mut self) -> &mut T { 63 | &mut self.0 64 | } 65 | } 66 | 67 | /// Wrapper around [std::cell::Cell] that implements [fmt::Debug] and has a few more useful utility 68 | /// funcitons. 69 | #[derive(Default)] 70 | pub struct Cell(std::cell::Cell); 71 | 72 | impl Cell { 73 | pub fn new(t: T) -> Self { 74 | Cell(std::cell::Cell::new(t)) 75 | } 76 | 77 | #[allow(dead_code)] 78 | pub fn into_inner(self) -> T { 79 | self.0.into_inner() 80 | } 81 | } 82 | 83 | impl Cell { 84 | #[inline(always)] 85 | pub fn take_in R, R>(&self, f: F) -> R { 86 | let mut t = self.0.take(); 87 | let rv = f(&mut t); 88 | self.0.set(t); 89 | rv 90 | } 91 | } 92 | 93 | impl Cell> { 94 | #[inline(always)] 95 | pub fn take_in_some R, R>(&self, f: F) -> Option { 96 | let mut t = self.0.take(); 97 | let rv = t.as_mut().map(f); 98 | self.0.set(t); 99 | rv 100 | } 101 | } 102 | 103 | impl std::ops::Deref for Cell { 104 | type Target = std::cell::Cell; 105 | fn deref(&self) -> &std::cell::Cell { 106 | &self.0 107 | } 108 | } 109 | 110 | impl fmt::Debug for Cell { 111 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 112 | write!(fmt, "Cell") 113 | } 114 | } 115 | 116 | /// A simple wrapper around [RawFd] that implements [AsRawFd]. 117 | /// 118 | /// Note: it does nothing on drop; the file descriptor lifetime must be managed elsewhere. 119 | #[derive(Debug)] 120 | pub struct Fd(pub RawFd); 121 | impl AsRawFd for Fd { 122 | fn as_raw_fd(&self) -> RawFd { 123 | self.0 124 | } 125 | } 126 | 127 | pub fn spawn_noerr(fut: impl Future + 'static) { 128 | tokio::task::spawn_local(fut); 129 | } 130 | 131 | pub fn spawn(owner: &'static str, fut: impl Future>> + 'static) { 132 | spawn_noerr(async move { 133 | match fut.await { 134 | Ok(()) => {} 135 | Err(e) => { 136 | error!("{}: {}", owner, e); 137 | } 138 | } 139 | }); 140 | } 141 | 142 | pub fn spawn_critical( 143 | owner: &'static str, 144 | fut: impl Future>> + 'static, 145 | ) { 146 | spawn_noerr(async move { 147 | match fut.await { 148 | Ok(i) => match i {}, 149 | Err(e) => { 150 | error!("{}: {}", owner, e); 151 | std::process::exit(0); 152 | } 153 | } 154 | }); 155 | } 156 | 157 | pub fn spawn_handle( 158 | owner: &'static str, 159 | fut: impl Future>> + 'static, 160 | ) -> RemoteHandle<()> { 161 | let (task, rh) = async move { 162 | match fut.await { 163 | Ok(()) => {} 164 | Err(e) => { 165 | error!("{}: {}", owner, e); 166 | } 167 | } 168 | } 169 | .remote_handle(); 170 | spawn_noerr(task); 171 | rh 172 | } 173 | 174 | pub fn glob_expand<'a>(file: impl Into>) -> Option<(Cow<'a, str>, bool)> { 175 | let file = file.into(); 176 | if !file.contains('*') { 177 | return Some((file, false)); 178 | } 179 | 180 | let mut candidates; 181 | let components; 182 | if file.starts_with("/") { 183 | candidates = vec![PathBuf::from("/")]; 184 | components = file[1..].split('/'); 185 | } else { 186 | candidates = vec![PathBuf::from(".")]; 187 | components = file.split('/'); 188 | } 189 | for component in components { 190 | if component.contains('*') { 191 | let mut re = String::new(); 192 | for (i, chunk) in component.split('*').enumerate() { 193 | if i == 0 { 194 | re.push('^'); 195 | } else { 196 | re.push_str(".*"); 197 | } 198 | re.push_str(®ex::escape(chunk)); 199 | } 200 | re.push('$'); 201 | let re = regex::Regex::new(&re).expect("Invalid regex"); 202 | candidates = candidates 203 | .into_iter() 204 | .filter_map(|c| fs::read_dir(c).ok()) 205 | .flatten() 206 | .filter_map(Result::ok) 207 | .filter(|e| match e.file_name().to_str() { 208 | Some(name) => re.is_match(name), 209 | None => false, 210 | }) 211 | .map(|e| e.path()) 212 | .collect(); 213 | } else { 214 | for p in &mut candidates { 215 | p.push(component); 216 | } 217 | } 218 | } 219 | let mut candidates = candidates 220 | .into_iter() 221 | .filter_map(|p| p.into_os_string().into_string().ok()); 222 | 223 | let c = candidates.next()?; 224 | Some((c.into(), candidates.next().is_some())) 225 | } 226 | 227 | #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] 228 | pub struct UID(u64); 229 | 230 | impl UID { 231 | pub fn new() -> Self { 232 | use std::sync::atomic::{AtomicU64, Ordering}; 233 | static N: AtomicU64 = AtomicU64::new(0); 234 | Self(N.fetch_add(1, Ordering::Relaxed)) 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/wlr.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | data::Value, 3 | state::{NotifierList, Runtime, State}, 4 | util::spawn, 5 | }; 6 | use std::{ 7 | cell::RefCell, 8 | collections::VecDeque, 9 | rc::{Rc, Weak}, 10 | }; 11 | // TODO use std::sync::Mutex; 12 | use bytes::{Bytes, BytesMut}; 13 | use futures_channel::oneshot; 14 | use futures_util::future::{select, Either}; 15 | use wayland_client::{protocol::wl_seat::WlSeat, Connection, Proxy, QueueHandle}; 16 | use wayland_protocols_wlr::data_control::v1::client::{ 17 | zwlr_data_control_device_v1, 18 | zwlr_data_control_offer_v1::{self, ZwlrDataControlOfferV1}, 19 | }; 20 | 21 | #[derive(Debug)] 22 | enum OfferValue { 23 | Available, 24 | Running { 25 | data: oneshot::Receiver, 26 | interested: Rc, 27 | }, 28 | Finished(Bytes), 29 | Failed, 30 | } 31 | 32 | #[derive(Debug)] 33 | struct OfferType { 34 | mime: Box, 35 | value: OfferValue, 36 | } 37 | 38 | #[derive(Debug)] 39 | pub struct OfferData { 40 | mimes: RefCell>, 41 | } 42 | 43 | // XXX this would need reworking to be threadsafe, rely on no threads 44 | unsafe impl Send for OfferData {} 45 | unsafe impl Sync for OfferData {} 46 | 47 | #[derive(Debug)] 48 | struct Clipboard { 49 | seat: WlSeat, 50 | selection: bool, 51 | contents: Option, 52 | interested: Vec>, 53 | } 54 | 55 | thread_local! { 56 | static CLIPBOARDS: RefCell>> = RefCell::new(None); 57 | } 58 | 59 | impl wayland_client::Dispatch 60 | for State 61 | { 62 | fn event( 63 | _: &mut Self, 64 | dcd: &zwlr_data_control_device_v1::ZwlrDataControlDeviceV1, 65 | event: zwlr_data_control_device_v1::Event, 66 | seat: &WlSeat, 67 | _: &Connection, 68 | _: &QueueHandle, 69 | ) { 70 | use zwlr_data_control_device_v1::Event; 71 | match event { 72 | Event::DataOffer { .. } => {} 73 | Event::Selection { id } => { 74 | set_seat_offer(seat, id, false); 75 | } 76 | Event::PrimarySelection { id } => { 77 | set_seat_offer(seat, id, true); 78 | } 79 | Event::Finished => { 80 | set_seat_offer(seat, None, false); 81 | set_seat_offer(seat, None, true); 82 | dcd.destroy(); 83 | } 84 | _ => {} 85 | } 86 | } 87 | 88 | wayland_client::event_created_child!(State, zwlr_data_control_device_v1::ZwlrDataControlDeviceV1, [ 89 | 0 => (ZwlrDataControlOfferV1, OfferData { 90 | mimes: RefCell::new(Vec::new()) 91 | }), 92 | ]); 93 | } 94 | 95 | impl wayland_client::Dispatch for State { 96 | fn event( 97 | _: &mut Self, 98 | _: &zwlr_data_control_offer_v1::ZwlrDataControlOfferV1, 99 | event: zwlr_data_control_offer_v1::Event, 100 | data: &OfferData, 101 | _: &Connection, 102 | _: &QueueHandle, 103 | ) { 104 | match event { 105 | zwlr_data_control_offer_v1::Event::Offer { mime_type } => { 106 | data.mimes.borrow_mut().push(OfferType { 107 | mime: mime_type.into(), 108 | value: OfferValue::Available, 109 | }); 110 | } 111 | _ => {} 112 | } 113 | } 114 | } 115 | 116 | fn start_dcm(rt: &Runtime) -> VecDeque { 117 | let mut rv = VecDeque::new(); 118 | match rt.wayland.wlr_dcm.get() { 119 | Ok(dcm) => { 120 | for seat in rt.wayland.seat.seats() { 121 | rv.push_back(Clipboard { 122 | seat: seat.clone(), 123 | selection: true, 124 | contents: None, 125 | interested: Vec::new(), 126 | }); 127 | rv.push_back(Clipboard { 128 | seat: seat.clone(), 129 | selection: false, 130 | contents: None, 131 | interested: Vec::new(), 132 | }); 133 | 134 | dcm.get_data_device(&seat, &rt.wayland.queue, seat.clone()); 135 | } 136 | } 137 | Err(e) => { 138 | log::error!("Clipboard not available: {e:?}"); 139 | } 140 | } 141 | rv 142 | } 143 | 144 | fn set_seat_offer(seat: &WlSeat, contents: Option, selection: bool) { 145 | CLIPBOARDS.with(|clips| { 146 | let mut clips = clips.borrow_mut(); 147 | let clips = clips.as_mut().unwrap(); 148 | for i in 0..clips.len() { 149 | let clip = &clips[i]; 150 | if clip.seat != *seat || clip.selection != selection { 151 | continue; 152 | } 153 | if let Some(prev) = &clip.contents { 154 | prev.destroy(); 155 | } 156 | // use the prior interest list to read the new clipboard 157 | let data = contents.as_ref().map(|c| c.data::()); 158 | for view in clip.interested.iter().filter_map(Weak::upgrade) { 159 | if let (Some(contents), &Some(Some(data))) = (&contents, &data) { 160 | if let Some(idx) = view.find_best_mime(data) { 161 | let mut mimes = data.mimes.borrow_mut(); 162 | let best = &mut mimes[idx]; 163 | view.start_read(contents, best); 164 | continue; 165 | } 166 | } 167 | view.interested.notify_data("empty-clipboard"); 168 | } 169 | clips.remove(i); 170 | break; 171 | } 172 | clips.push_front(Clipboard { 173 | seat: seat.clone(), 174 | selection, 175 | contents, 176 | interested: Vec::new(), 177 | }); 178 | }); 179 | } 180 | 181 | #[derive(Debug)] 182 | pub struct ClipboardData { 183 | pub seat: Option>, 184 | pub mime_list: Vec>, 185 | pub selection: bool, 186 | pub interested: NotifierList, 187 | } 188 | 189 | impl ClipboardData { 190 | fn find_best_mime(&self, data: &OfferData) -> Option { 191 | let offered = data.mimes.borrow(); 192 | if self.mime_list.is_empty() { 193 | for &mime in &[ 194 | "text/plain;charset=utf-8", 195 | "text/plain", 196 | "UTF8_STRING", 197 | "STRING", 198 | "TEXT", 199 | ] { 200 | if let Some(i) = offered.iter().position(|t| &*t.mime == mime) { 201 | return Some(i); 202 | } 203 | } 204 | } else { 205 | for mime in &self.mime_list { 206 | if let Some(i) = offered.iter().position(|t| &t.mime == mime) { 207 | return Some(i); 208 | } 209 | } 210 | } 211 | None 212 | } 213 | 214 | fn start_read(&self, contents: &ZwlrDataControlOfferV1, offer: &mut OfferType) { 215 | match &mut offer.value { 216 | OfferValue::Available => { 217 | let (mut send, recv) = oneshot::channel(); 218 | let (tx, rx) = match std::os::unix::net::UnixStream::pair() { 219 | Ok(p) => p, 220 | Err(_) => { 221 | offer.value = OfferValue::Failed; 222 | return; 223 | } 224 | }; 225 | use std::os::fd::AsFd; 226 | contents.receive(String::from(&*offer.mime), tx.as_fd()); 227 | 228 | let interested = Rc::new(NotifierList::default()); 229 | interested.merge(&self.interested); 230 | offer.value = OfferValue::Running { 231 | data: recv, 232 | interested: interested.clone(), 233 | }; 234 | 235 | spawn("Clipboard read", async move { 236 | use tokio::io::AsyncReadExt; 237 | let mut rx = tokio::net::UnixStream::from_std(rx)?; 238 | let mut buf = BytesMut::new(); 239 | let mut cancel = send.cancellation(); 240 | loop { 241 | let read = rx.read_buf(&mut buf); 242 | futures_util::pin_mut!(read); 243 | match select(cancel, read).await { 244 | Either::Left(_) => return Ok(()), 245 | Either::Right((Ok(0), _)) => break, 246 | Either::Right((rv, c)) => { 247 | rv?; 248 | cancel = c; 249 | } 250 | } 251 | } 252 | drop(send.send(buf.into())); 253 | interested.notify_data("clipboard-data"); 254 | Ok(()) 255 | }); 256 | } 257 | OfferValue::Running { data, interested } => { 258 | interested.merge(&self.interested); 259 | match data.try_recv() { 260 | Ok(Some(v)) => { 261 | offer.value = OfferValue::Finished(v); 262 | } 263 | Ok(None) => {} 264 | Err(_) => { 265 | offer.value = OfferValue::Failed; 266 | } 267 | } 268 | } 269 | OfferValue::Finished(_) => {} 270 | OfferValue::Failed => {} 271 | } 272 | } 273 | 274 | pub fn read_in R, R>( 275 | self: &Rc, 276 | _name: &str, 277 | key: &str, 278 | rt: &Runtime, 279 | f: F, 280 | ) -> R { 281 | self.interested.add(rt); 282 | CLIPBOARDS.with(|clips| { 283 | let mut clips = clips.borrow_mut(); 284 | let clips = clips.get_or_insert_with(|| start_dcm(rt)); 285 | for clip in &mut *clips { 286 | if clip.selection != self.selection { 287 | continue; 288 | } 289 | if let Some(seat) = &self.seat { 290 | if rt 291 | .wayland 292 | .seat 293 | .info(&clip.seat) 294 | .map(|data| data.name.as_deref() == Some(&**seat)) 295 | != Some(true) 296 | { 297 | continue; 298 | } 299 | } 300 | 301 | if clip 302 | .interested 303 | .iter() 304 | .all(|e| e.as_ptr() != Rc::as_ptr(self)) 305 | { 306 | clip.interested.push(Rc::downgrade(self)); 307 | } 308 | 309 | if let Some(contents) = &clip.contents { 310 | if let Some(data) = contents.data::() { 311 | if let Some(idx) = self.find_best_mime(data) { 312 | let mut mimes = data.mimes.borrow_mut(); 313 | let best = &mut mimes[idx]; 314 | if key == "mime" { 315 | return f(Value::Borrow(&best.mime)); 316 | } 317 | 318 | self.start_read(contents, best); 319 | 320 | if let OfferValue::Finished(v) = &best.value { 321 | return f(String::from_utf8_lossy(&v).into()); 322 | } else { 323 | return f(Value::Null); 324 | } 325 | } 326 | } 327 | } 328 | return f(Value::Null); 329 | } 330 | f(Value::Null) 331 | }) 332 | } 333 | } 334 | --------------------------------------------------------------------------------