This is an overview of my screenscale
project that i worked on today.
I use the sway
windowing manager built on the wayland
compositor.
One can interface with sway through the swaymsg
command line utility, which can
send messages to the running instance of sway to query for and update attributes, including
screen resolution and hi-dpi scaling (which is what we want to leverage today).
one thing we can do with swaymsg
is to query for all of the current outputs,
which for me, runs as such:
$ swaymsg -t get_outputs
Output eDP-1 'Unknown 0x095F 0x00000000' (focused)
Current mode: 2256x1504 @ 59.999 Hz
Position: 0,0
Scale factor: 2.000000
Scale filter: nearest
Subpixel hinting: rgb
Transform: normal
Workspace: 2
Max render time: off
Adaptive sync: disabled
Available modes:
2256x1504 @ 47.998 Hz
2256x1504 @ 59.999 Hz
Note in particular the Scale factor: 2
.
With the laptop that I'm using, it is easiest to read
when the screen is scaled by some value greater than 1.
For example, when scaled by 2, the effective screen would be:
1128x752 pixels instead of the native 2256x1504.
As we'll use the following in the future, here is an example of setting the scale factor:
$ swaymsg output eDP-1 scale 1
Which would change the scale factor back to 1, so that the effective screen would be back to the full: 2256x1504 pixels.
I wanted a way to control the scale factor from some combination of hotkeys (likely the media keys with some modifiers).
To start I knew that I would need some set of scale factors that I wanted to toggle between, and some way to move up and down through those values.
I had a collection of values that I had been manually swapping between; simplified for this summary, these numbers are useful scale factors to move between:
1
1.41
1.7625
2
These multipliers mostly end up with nice pixel values for the width and height of my screen, and are spaced far enough apart from each other that they feel like useful jumps, while not being too jarring to jump between.
I knew that I would want some simple Rust code that would jump between the scale factors, likely given a list of scale factors and whether to go up or down in that list of scale factors.
I ended up with:
- The enum
Direction
which signalled to move up or down (or bottom or top)- A parser that tried to parse this enum from string representations
- A function
closest_up
- which moves to the next largest value from a given point
- A function
closest_down
- which moves to the next smallest value from a given point
- A function
resolve_new_scale
- which selects the next value in a list, up or down, from a given point
- A function
resolve_screen_scales_from_config_file
- which parses out all of the screen values to move between from a config file
- A
main
function- that parses the inputs from the command line and calls into the appropriate functions above
- this function prints out the scale factor to set, which the consumer of this will expect
Here is example usage of this Rust code, and then the code below that:
(with the following config file)
$ cat ~/.config/screenscale/small
1
1.41
1.7625
2
$ rust_screen_scale down 1.6 ~/.config/screenscale/small
1.41
$ rust_screen_scale up 1.6 ~/.config/screenscale/small
1.7625
The Final Rust Code:
use std::env;
use std::fs;
use std::num;
pub enum Direction {
Bottom,
Down,
Up,
Top,
}
impl Direction {
pub fn parse(s: &String) -> Result<Direction, String> {
match s.as_str() {
"bottom" => Ok(Direction::Bottom),
"down" => Ok(Direction::Down),
"up" => Ok(Direction::Up),
"top" => Ok(Direction::Top),
_ => Err("<direction> must be one of 'up' or 'down'".to_string()),
}
}
}
fn closest_up(values: Vec<f32>, current: f32) -> f32 {
for value in values.iter() {
if current < *value {
return value.clone();
}
}
return values[values.len() - 1];
}
fn closest_down(values: Vec<f32>, current: f32) -> f32 {
for value in values.iter().rev() {
if current > *value {
return value.clone();
}
}
return values[0];
}
fn resolve_new_scale(direction: Direction, current_scale: f32, mut scales: Vec<f32>) -> f32 {
scales.sort_by(|a, b| a.partial_cmp(b).unwrap());
match direction {
Direction::Bottom => scales[0],
Direction::Down => closest_down(scales, current_scale),
Direction::Up => closest_up(scales, current_scale),
Direction::Top => scales[scales.len() - 1],
}
}
fn resolve_screen_scales_from_config_file(config_file_path: String) -> Result<Vec<f32>, String> {
let file_contents = fs::read_to_string(config_file_path).map_err(|e| format!("{:?}", e))?;
let scales = file_contents
.split("\n")
.into_iter()
.filter(|line| line.len() != 0)
.filter(|line| !line.starts_with('#'))
.map(|line| line.parse::<f32>())
.collect::<Result<Vec<f32>, num::ParseFloatError>>()
.map_err(|e| format!("{:?}", e))?;
return Ok(scales);
}
fn main() -> Result<(), String> {
let args: Vec<String> = env::args().collect();
if args.len() != 4 {
return Err("Must supply <direction>, <current-scale>, and <config-file-path>".to_string());
}
let direction = Direction::parse(&args[1])?;
let scale = args[2]
.parse::<f32>()
.map_err(|_| "<current-scale> must be a floating point number".to_string())?;
let config_file_path = args[3].clone();
let scales = resolve_screen_scales_from_config_file(config_file_path)?;
let new_scale = resolve_new_scale(direction, scale, scales);
println!("{}", new_scale);
return Ok(());
}
I didn't really want to deal with subprocesses or including crates to communicate with sway directly, so I decided to make the executable that I actually call into a shell script that calls into this Rust code.
This bash code:
- Takes the operation (up, down, etc)
- Resolves a config file name
- Queries for the current screen scale
and then calls into the Rust code appropriately and sets the scale with the output it gets from the Rust code.
The only other pieces of noteworth information here being:
- It can just
get
the current screen scale if the user provides theget
argument - It has a default config file if the caller does not provide a config file
- It will print out the current effective-resolution and scale after it is done setting the scale (or passing if it was told to
get
)
Some Example usage of this bash code would be:
$ screenscale down ~/.config/screenscale/small
1600x1066 (1.409999966621399)
$ screenscale up ~/.config/screenscale/small
1280x853 (1.7625000476837158)
And the Final Bash Code looks like this:
#!/bin/bash
set -e
if [ $# -eq 0 ]; then
echo "must supply command (e.g. up, down, bottom, top, get)"
exit
fi
OPERATION=$1
CURRENT_SCALE=$(swaymsg -t get_outputs | jq '.[0].scale')
if [ $OPERATION != "get" ]; then
CONFIG_FILE=~/.config/screenscale/config
if [ $# -eq 2 ]; then
CONFIG_FILE=$2
fi
SCALE_TO_SET=$(rust_screen_scale $OPERATION $CURRENT_SCALE $CONFIG_FILE)
swaymsg output eDP-1 scale $SCALE_TO_SET
fi
CURRENT_SWAY_OUTPUT=$(swaymsg -t get_outputs | jq '.')
CURRENT_SCALE=$(echo $CURRENT_SWAY_OUTPUT | jq '.[0].scale')
CURRENT_X=$(echo $CURRENT_SWAY_OUTPUT | jq '.[0].rect.width')
CURRENT_Y=$(echo $CURRENT_SWAY_OUTPUT | jq '.[0].rect.height')
echo "${CURRENT_X}x${CURRENT_Y} (${CURRENT_SCALE})"
After we have all of this logic, we want to be able to use it from the most convenient place of all: the keyboard!
I bound the brightness media keys with modifiers to call into the bash script a few different ways.
I knew that I wanted to be able to move up and down through the standard scales that I mentioned earlier:
1
1.41
1.7625
2
but I also figured that there may be more resolutions that I want to hop between, so I built this larger list of scales that may be useful to scan through:
1
1.128
1.175
1.25
1.41
1.504
1.6
1.7625
1.88
2
2.256
2.4
2.5
2.82
3
3.2
3.5
3.76
4
5
6
7.52
8
9.024
and I figured that sometimes I may just want to move to the largest or smallest scale,
so I have instrumented a top
and bottom
construct throughout these layers.
With all of this in mind, I leverage the default config resolution of the script to resolve to the short list of scales, and only specify the large list of scales when I want the finer-grain control.
The end result is the following bindings:
### Screen Hi-DPI Scale
bindsym $mod+Control+XF86MonBrightnessUp exec screenscale top
bindsym $mod+Shift+XF86MonBrightnessUp exec screenscale up ~/.config/screenscale/large
bindsym $mod+XF86MonBrightnessUp exec screenscale up
bindsym $mod+XF86MonBrightnessDown exec screenscale down
bindsym $mod+Shift+XF86MonBrightnessDown exec screenscale down ~/.config/screenscale/large
bindsym $mod+Control+XF86MonBrightnessDown exec screenscale bottom
I will definitely making this parameterizable over a screen name (as currently this will only work with a screen eDP-1) and may make some other improvements (like better error handling) but this was a very fun day project, and I love having this tight control to move my computer in the exact ways that I need, even if it does take some extra effort to do so.