diff --git a/Cargo.lock b/Cargo.lock index a1e6c86..6f15179 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "anstream" -version = "0.6.14" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", @@ -19,33 +19,33 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.7" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.3" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ "windows-sys", ] [[package]] name = "anstyle-wincon" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ "anstyle", "windows-sys", @@ -53,9 +53,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.4" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" dependencies = [ "clap_builder", "clap_derive", @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.2" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" dependencies = [ "anstream", "anstyle", @@ -75,9 +75,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.4" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck", "proc-macro2", @@ -87,15 +87,27 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" [[package]] name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "hashbrown" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" [[package]] name = "heck" @@ -104,10 +116,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "is_terminal_polyfill" -version = "1.70.0" +name = "i3" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "indexmap" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itoa" @@ -116,19 +146,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] -name = "proc-macro2" -version = "1.0.82" +name = "memchr" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -144,24 +180,26 @@ name = "screencfg" version = "0.1.0" dependencies = [ "clap", + "i3", "serde", - "serde_json", + "toml", + "xrandr", ] [[package]] name = "serde" -version = "1.0.200" +version = "1.0.214" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" +checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.200" +version = "1.0.214" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb" +checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" dependencies = [ "proc-macro2", "quote", @@ -170,15 +208,25 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.116" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "strsim" version = "0.11.1" @@ -187,9 +235,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.61" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c993ed8ccba56ae856363b1845da7266a7cb78e1d146c8a32d54b45a8b831fc9" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -197,31 +245,65 @@ dependencies = [ ] [[package]] -name = "unicode-ident" -version = "1.0.12" +name = "toml" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] [[package]] name = "windows-targets" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", @@ -235,48 +317,61 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "xrandr" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 94a1e70..604a8ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,222 @@ [package] name = "screencfg" -description = "Automatically configure your screen setup" +description = "Automatically configure your screen setup with i3" version = "0.1.0" edition = "2021" +repository = "https://github.com/hakoerber/screencfg/" +authors = ["Hannes Körber "] +rust-version = "1.74.1" +readme = "README.md" +license-file = "LICENSE" +keywords = ["i3", "xrandr"] +categories = ["command-line-utilities"] [dependencies] -clap = { version = "4.5.4", default-features = false, features = ["std", "derive"] } -serde = { version = "1.0.200", features = ["derive"] } -serde_json = "1.0.116" +clap = { version = "4.*", default-features = false, features = [ + "std", + "derive", +] } +toml = { version = "0.8.*", default-features = false, features = ["parse"] } +i3 = { path = "./i3" } +xrandr = { path = "./xrandr" } +serde = { version = "1.0.214", default-features = false, features = ["derive"] } [features] default = ["full"] -full = ["clap/help", "clap/color", "clap/suggestions", "clap/usage", "clap/error-context"] +full = [ + "clap/help", + "clap/color", + "clap/suggestions", + "clap/usage", + "clap/error-context", +] + +[workspace] +resolver = "2" +members = ["i3", "xrandr"] + +[profile.release] +opt-level = 3 +debug = false +strip = "symbols" +debug-assertions = false +overflow-checks = false +lto = "fat" +panic = "abort" +codegen-units = 1 +rpath = false + +[workspace.lints.rust] +absolute_paths_not_starting_with_crate = "deny" +elided_lifetimes_in_paths = "deny" +explicit_outlives_requirements = "deny" +keyword_idents = "deny" +let_underscore_drop = "deny" +non_ascii_idents = "deny" +non_local_definitions = "deny" +single_use_lifetimes = "deny" +unit_bindings = "deny" +unreachable_pub = "deny" +unsafe_code = { level = "forbid", priority = -1 } +unsafe_op_in_unsafe_fn = "deny" +unstable_features = { level = "forbid", priority = -1 } +unused_crate_dependencies = "deny" +unused_import_braces = "deny" +unused_lifetimes = "deny" +unused_macro_rules = "deny" +unused_qualifications = "deny" +unused_results = "deny" +variant_size_differences = "deny" + +[workspace.lints.clippy] +# enabled groups +correctness = { level = "deny", priority = -1 } +suspicious = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } + +# pedantic overrides +too_many_lines = "allow" +must_use_candidate = "allow" +map_unwrap_or = "allow" +missing_errors_doc = "allow" + +# nursery overrides +missing_const_for_fn = "allow" +option_if_let_else = "allow" + +# complexity overrides +too_many_arguments = "allow" + +# style overrides +new_without_default = "allow" + +# cargo overrides +multiple_crate_versions = "allow" +cargo_common_metadata = "allow" + +# selected restrictions +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +arithmetic_side_effects = "warn" +as_conversions = "warn" +assertions_on_result_states = "warn" +cfg_not_test = "warn" +clone_on_ref_ptr = "warn" +create_dir = "warn" +dbg_macro = "warn" +decimal_literal_representation = "warn" +default_numeric_fallback = "warn" +deref_by_slicing = "warn" +disallowed_script_idents = "warn" +else_if_without_else = "warn" +empty_drop = "warn" +empty_enum_variants_with_brackets = "warn" +empty_structs_with_brackets = "warn" +exit = "warn" +filetype_is_file = "warn" +float_arithmetic = "warn" +float_cmp_const = "warn" +fn_to_numeric_cast_any = "warn" +format_push_string = "warn" +get_unwrap = "warn" +indexing_slicing = "warn" +infinite_loop = "warn" +inline_asm_x86_att_syntax = "warn" +inline_asm_x86_intel_syntax = "warn" +integer_division = "warn" +iter_over_hash_type = "warn" +large_include_file = "warn" +let_underscore_must_use = "warn" +let_underscore_untyped = "warn" +little_endian_bytes = "warn" +lossy_float_literal = "warn" +map_err_ignore = "warn" +mem_forget = "warn" +missing_assert_message = "warn" +missing_asserts_for_indexing = "warn" +mixed_read_write_in_expression = "warn" +modulo_arithmetic = "warn" +multiple_inherent_impl = "warn" +multiple_unsafe_ops_per_block = "warn" +mutex_atomic = "warn" +panic = "warn" +partial_pub_fields = "warn" +pattern_type_mismatch = "warn" +print_stderr = "warn" +print_stdout = "warn" +pub_without_shorthand = "warn" +rc_buffer = "warn" +rc_mutex = "warn" +redundant_type_annotations = "warn" +renamed_function_params = "warn" +rest_pat_in_fully_bound_structs = "warn" +same_name_method = "warn" +self_named_module_files = "warn" +semicolon_outside_block = "warn" +str_to_string = "warn" +string_add = "warn" +string_lit_chars_any = "warn" +string_slice = "warn" +string_to_string = "warn" +suspicious_xor_used_as_pow = "warn" +tests_outside_test_module = "warn" +todo = "warn" +try_err = "warn" +undocumented_unsafe_blocks = "warn" +unimplemented = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unnecessary_self_imports = "warn" +unneeded_field_pattern = "warn" +unseparated_literal_suffix = "warn" +unused_result_ok = "warn" +unwrap_used = "warn" +use_debug = "warn" +verbose_file_reads = "warn" + +# restrictions explicit allows +absolute_paths = "allow" +alloc_instead_of_core = "allow" +as_underscore = "allow" +big_endian_bytes = "allow" +default_union_representation = "allow" +error_impl_error = "allow" +exhaustive_enums = "allow" +exhaustive_structs = "allow" +expect_used = "allow" +field_scoped_visibility_modifiers = "allow" +host_endian_bytes = "allow" +if_then_some_else_none = "allow" +impl_trait_in_params = "allow" +implicit_return = "allow" +integer_division_remainder_used = "allow" +min_ident_chars = "allow" +missing_docs_in_private_items = "allow" +missing_inline_in_public_items = "allow" +missing_trait_methods = "allow" +mod_module_files = "allow" +needless_raw_strings = "allow" +non_ascii_literal = "allow" +panic_in_result_fn = "allow" +pathbuf_init_then_push = "allow" +pub_use = "allow" +pub_with_shorthand = "allow" +question_mark_used = "allow" +ref_patterns = "allow" +semicolon_inside_block = "allow" +separated_literal_suffix = "allow" +shadow_reuse = "allow" +shadow_same = "allow" +shadow_unrelated = "allow" +single_call_fn = "allow" +single_char_lifetime_names = "allow" +std_instead_of_alloc = "allow" +std_instead_of_core = "allow" +unreachable = "allow" +unwrap_in_result = "allow" +wildcard_enum_match_arm = "allow" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4a9e655 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: check +check: | fmt lint test + +.PHONY: docs +docs: + cargo watch -- cargo doc + +.PHONY: test +test: + cargo hack --feature-powerset --no-dev-deps check + cargo test --workspace --color=always + +.PHONY: lint +lint: + cargo clippy --workspace --tests --color=always + +.PHONY: fmt +fmt: + cargo fmt + find -name '*.md' | xargs prettier --print-width 80 --prose-wrap always --write + find -name '*.toml' | xargs taplo format + +.PHONY: build-static +build-static: + cargo build --target x86_64-unknown-linux-musl --no-default-features --release --workspace diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/i3/Cargo.toml b/i3/Cargo.toml new file mode 100644 index 0000000..1ac2ae3 --- /dev/null +++ b/i3/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "i3" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.*", default-features = false, features = ["derive"] } +serde_json = { version = "1.*", default-features = false, features = ["std"] } + +[lints] +workspace = true diff --git a/i3/src/error.rs b/i3/src/error.rs new file mode 100644 index 0000000..d50cf53 --- /dev/null +++ b/i3/src/error.rs @@ -0,0 +1,67 @@ +use std::{fmt, io}; + +#[derive(Debug)] +pub enum Msg { + Owned(String), + Static(&'static str), +} + +impl From<&'static str> for Msg { + fn from(value: &'static str) -> Self { + Self::Static(value) + } +} + +impl From for Msg { + fn from(value: String) -> Self { + Self::Owned(value) + } +} + +impl fmt::Display for Msg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match *self { + Self::Owned(ref s) => s.as_str(), + Self::Static(s) => s, + } + ) + } +} + +#[derive(Debug)] +pub enum Error { + Connection(Msg), + Command(Msg), + Protocol(Msg), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match *self { + Self::Connection(ref msg) => format!("connection failed: {msg}"), + Self::Command(ref msg) => format!("command failed: {msg}"), + Self::Protocol(ref msg) => format!("overflow: {msg}"), + } + ) + } +} + +impl From for Error { + fn from(value: io::Error) -> Self { + Self::Command(Msg::Owned(value.to_string())) + } +} + +impl From for Error { + fn from(value: serde_json::Error) -> Self { + Self::Connection(Msg::Owned(value.to_string())) + } +} + +impl std::error::Error for Error {} diff --git a/i3/src/lib.rs b/i3/src/lib.rs new file mode 100644 index 0000000..fa73f70 --- /dev/null +++ b/i3/src/lib.rs @@ -0,0 +1,619 @@ +use std::{ + borrow::Cow, + ffi::OsStr, + fmt, + io::{Read, Write}, + ops::{Deref, DerefMut, Index}, + os::unix::{ffi::OsStrExt as _, net}, + path::PathBuf, + process, + time::Duration, + vec::IntoIter, +}; + +mod error; +pub use error::Error; + +pub enum Command { + Nop, + MoveWorkspace { id: usize, output: String }, +} + +impl From<&Command> for Cow<'static, str> { + fn from(value: &Command) -> Self { + match *value { + Command::Nop => Cow::from("nop"), + Command::MoveWorkspace { id, ref output } => Cow::from(format!( + "[workspace=\"{id}\"] move workspace to output {output}" + )), + } + } +} + +pub trait Conn { + fn version(&mut self) -> Result; + fn outputs(&mut self) -> Result; + fn workspaces(&mut self) -> Result; + fn command(&mut self, command: Command) -> Result<(), Error>; +} + +pub enum MockSetting { + LaptopOnly, + ExternalOnly(usize), + Mixed, +} + +pub struct MockConnection { + pub fail: bool, + pub setting: MockSetting, +} + +impl MockConnection { + fn check_fail(&self) -> Result<(), Error> { + if self.fail { + Err(Error::Connection("fail".into())) + } else { + Ok(()) + } + } +} + +impl Conn for MockConnection { + fn version(&mut self) -> Result { + self.check_fail()?; + Ok(Version { + minor: 1, + patch: 2, + major: 3, + }) + } + + fn outputs(&mut self) -> Result { + self.check_fail()?; + match self.setting { + MockSetting::LaptopOnly => Ok(Outputs(vec![Output { + name: "eDP-1".into(), + active: true, + primary: true, + }])), + MockSetting::ExternalOnly(num) => match num { + 1 => Ok(Outputs(vec![Output { + name: "DP-1".into(), + active: true, + primary: false, + }])), + 2 => Ok(Outputs(vec![ + Output { + name: "DP-1".into(), + active: true, + primary: false, + }, + Output { + name: "DP-2".into(), + active: false, + primary: false, + }, + ])), + #[expect(clippy::panic, reason = "just a mock")] + _ => panic!(), + }, + MockSetting::Mixed => Ok(Outputs(vec![ + Output { + name: "eDP-1".into(), + active: true, + primary: true, + }, + Output { + name: "HDMI-1".into(), + active: true, + primary: false, + }, + Output { + name: "DP-1".into(), + active: true, + primary: false, + }, + ])), + } + } + + fn workspaces(&mut self) -> Result { + self.check_fail()?; + match self.setting { + MockSetting::LaptopOnly => Ok(Workspaces(vec![ + Workspace { + num: 1, + name: "num1".into(), + output: "eDP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "eDP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "eDP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "eDP-1".into(), + }, + ])), + MockSetting::ExternalOnly(num) => match num { + 1 => Ok(Workspaces(vec![ + Workspace { + num: 1, + name: "num1".into(), + output: "DP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "DP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "DP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "DP-1".into(), + }, + ])), + 2 => Ok(Workspaces(vec![ + Workspace { + num: 1, + name: "num1".into(), + output: "DP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "DP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "DP-2".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "DP-2".into(), + }, + ])), + #[expect(clippy::panic, reason = "just a mock")] + _ => panic!(), + }, + MockSetting::Mixed => Ok(Workspaces(vec![ + Workspace { + num: 1, + name: "num1".into(), + output: "eDP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "eDP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "DP-1".into(), + }, + Workspace { + num: 1, + name: "num1".into(), + output: "HDMI-1".into(), + }, + ])), + } + } + + fn command(&mut self, _command: Command) -> Result<(), Error> { + self.check_fail()?; + Ok(()) + } +} + +pub struct Connection(net::UnixStream); + +impl Conn for Connection { + fn version(&mut self) -> Result { + Message::Version.send(self)?; + + let response = Response::read(self)?; + + match response { + Response::Version(version) => Ok(version.into()), + Response::Workspaces(_) | Response::Command(_) | Response::Outputs(_) => Err( + Error::Connection("received invalid response from i3".into()), + ), + } + } + + fn outputs(&mut self) -> Result { + Message::Outputs.send(self)?; + let response = Response::read(self)?; + + match response { + Response::Outputs(outputs) => Ok(outputs.into()), + Response::Version(_) | Response::Workspaces(_) | Response::Command(_) => Err( + Error::Connection("received invalid response from i3".into()), + ), + } + } + + fn workspaces(&mut self) -> Result { + Message::Workspaces.send(self)?; + + let response = Response::read(self)?; + + match response { + Response::Workspaces(workspaces) => Ok(workspaces.into()), + Response::Version(_) | Response::Command(_) | Response::Outputs(_) => Err( + Error::Connection("received invalid response from i3".into()), + ), + } + } + + fn command(&mut self, command: Command) -> Result<(), Error> { + Message::Command(command).send(self)?; + + let response = Response::read(self)?; + match response { + Response::Command(commands) => { + for payload in commands { + if !payload.success { + return Err(Error::Command(match payload.error { + Some(err) => err.into(), + None => "unknown error".into(), + })); + } + } + Ok(()) + } + Response::Version(_) | Response::Workspaces(_) | Response::Outputs(_) => Err( + Error::Connection("received invalid response from i3".into()), + ), + } + } +} + +fn get_socketpath() -> Result { + let cmd = process::Command::new("i3") + .arg("--get-socketpath") + .output()?; + + let bytes = cmd + .stdout + .into_iter() + .take_while(|c| *c != b'\n') + .collect::>(); + + let string = OsStr::from_bytes(&bytes); + + let path = PathBuf::from(string); + + Ok(path) +} + +pub fn connect() -> Result { + let socketpath = get_socketpath()?; + + let socket = net::SocketAddr::from_pathname(socketpath)?; + + let stream = net::UnixStream::connect_addr(&socket)?; + stream.set_read_timeout(Some(Duration::from_millis(100)))?; + + Ok(Connection(stream)) +} + +#[derive(Debug, serde::Deserialize)] +struct OutputPayload { + name: String, + active: bool, + primary: bool, +} + +#[derive(Debug)] +pub struct Output { + pub name: String, + pub active: bool, + pub primary: bool, +} + +impl fmt::Display for Output { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name)?; + if self.active { + write!(f, " [active]")?; + } + if self.primary { + write!(f, " [primary]")?; + } + Ok(()) + } +} + +impl From for Output { + fn from(value: OutputPayload) -> Self { + Self { + name: value.name, + active: value.active, + primary: value.primary, + } + } +} + +#[derive(Debug)] +pub struct Workspaces(Vec); + +impl From> for Workspaces { + fn from(value: Vec) -> Self { + Self(value.into_iter().map(Into::into).collect()) + } +} + +impl IntoIterator for Workspaces { + type Item = Workspace; + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl Deref for Workspaces { + type Target = [Workspace]; + + fn deref(&self) -> &[Workspace] { + &self.0 + } +} +impl DerefMut for Workspaces { + fn deref_mut(&mut self) -> &mut [Workspace] { + &mut self.0 + } +} + +#[derive(Debug)] +pub struct Outputs(Vec); + +impl From> for Outputs { + fn from(value: Vec) -> Self { + Self( + value + .into_iter() + .filter(|output| output.name != "xroot-0") + .map(Into::into) + .collect(), + ) + } +} + +impl IntoIterator for Outputs { + type Item = Output; + type IntoIter = as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl Deref for Outputs { + type Target = [Output]; + + fn deref(&self) -> &[Output] { + &self.0 + } +} +impl DerefMut for Outputs { + fn deref_mut(&mut self) -> &mut [Output] { + &mut self.0 + } +} + +impl Index for Outputs { + type Output = Output; + + #[expect( + clippy::indexing_slicing, + reason = "transparent slicing, panicking is ok" + )] + fn index(&self, index: usize) -> &Self::Output { + &self.0[index] + } +} + +enum Message { + Command(Command), + Workspaces, + Outputs, + Version, +} + +impl From for u32 { + fn from(value: Message) -> Self { + match value { + Message::Command(_) => 0, + Message::Workspaces => 1, + Message::Outputs => 3, + Message::Version => 7, + } + } +} + +impl Message { + fn bytes(self) -> Result, Error> { + let payload: Option> = match self { + Self::Command(ref command) => Some(command.into()), + Self::Workspaces | Self::Outputs | Self::Version => None, + }; + + let mut message: Vec = vec![]; + let command_number: u32 = self.into(); + + message.extend_from_slice(b"i3-ipc"); + message.extend_from_slice( + &u32::try_from(payload.as_ref().map_or(0, |l| l.len())) + .map_err(|_err| Error::Command("payload length bigger than 4 bytes".into()))? + .to_ne_bytes(), + ); + message.extend_from_slice(&(command_number.to_ne_bytes())); + if let Some(payload) = payload { + message.extend_from_slice(payload.as_bytes()); + } + Ok(message) + } + + fn send(self, socket: &mut Connection) -> Result<(), Error> { + let message = self.bytes()?; + socket.0.write_all(&message)?; + Ok(()) + } +} + +#[derive(Debug, serde::Deserialize)] +#[expect(dead_code, reason = "external data defintion")] +struct VersionPayload { + human_readable: String, + loaded_config_file_name: String, + major: usize, + minor: usize, + patch: usize, +} + +pub struct Version { + minor: usize, + patch: usize, + major: usize, +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +impl From for Version { + fn from(value: VersionPayload) -> Self { + Self { + major: value.major, + minor: value.minor, + patch: value.patch, + } + } +} + +#[derive(Debug, serde::Deserialize)] +#[expect(dead_code, reason = "external data defintion")] +struct WorkspacePayload { + id: usize, + num: usize, + name: String, + output: String, +} + +#[derive(Debug, serde::Deserialize)] +struct CommandPayload { + success: bool, + error: Option, +} + +#[derive(Debug)] +pub struct Workspace { + pub num: usize, + pub name: String, + pub output: String, +} + +impl fmt::Display for Workspace { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} on {}", self.num, self.output) + } +} + +impl From for Workspace { + fn from(value: WorkspacePayload) -> Self { + Self { + num: value.num, + name: value.name, + output: value.output, + } + } +} + +#[derive(Debug)] +enum Response { + Version(VersionPayload), + Workspaces(Vec), + Command(Vec), + Outputs(Vec), +} + +impl Response { + fn read(stream: &mut Connection) -> Result { + let mut response = vec![ + 0; + "i3-ipc".chars().count().checked_add(4 + 4).ok_or_else(|| { + Error::Protocol("payload length overflowed".into()) + })? + ]; + + stream.0.read_exact(&mut response)?; + + if &response + .get(0..6) + .ok_or_else(|| Error::Protocol("response too short for even the magic string".into()))? + != b"i3-ipc" + { + return Err(Error::Protocol("magic string not found".into())); + } + let response_length = { + let bytes = response + .get(6..10) + .ok_or_else(|| Error::Protocol("not enough bytes for response length".into()))?; + + let bytes = bytes + .try_into() + .expect("slice of length 4 can always be converted into an array of size 4"); + + u32::from_ne_bytes(bytes) + }; + + let response_command = { + let bytes = response + .get(10..14) + .ok_or_else(|| Error::Protocol("not enough bytes for command".into()))?; + + let bytes = bytes + .try_into() + .expect("slice of length 4 can always be converted into an array of size 4"); + + u32::from_ne_bytes(bytes) + }; + + response = vec![ + 0; + response_length + .try_into() + .map_err(|_err| { Error::Protocol("u32 overflowed usize".into()) })? + ]; + + stream.0.read_exact(&mut response)?; + + match response_command { + 0 => Ok(Self::Command(serde_json::from_slice(&response)?)), + 1 => Ok(Self::Workspaces(serde_json::from_slice(&response)?)), + 3 => Ok(Self::Outputs(serde_json::from_slice(&response)?)), + 7 => Ok(Self::Version(serde_json::from_slice(&response)?)), + _ => Err(Error::Connection("unknown response type".into())), + } + } +} diff --git a/pkg/arch/.SRCINFO b/pkg/arch/.SRCINFO index 2f2f5e9..d6318d2 100644 --- a/pkg/arch/.SRCINFO +++ b/pkg/arch/.SRCINFO @@ -1,17 +1,12 @@ -pkgbase = screencfg-git +pkgbase = screencfg pkgdesc = Automatically configure your screen setup - pkgver = 0.1.r0.g1e8bf1d + pkgver = 0.1 pkgrel = 1 - url = https://github.com/hakoerber/screencfg arch = x86_64 license = GPL-3.0-only makedepends = cargo makedepends = git depends = glibc depends = gcc-libs - provides = screencfg - conflicts = screencfg - source = screencfg-git::git+https://github.com/hakoerber/screencfg#branch=master - sha256sums = SKIP -pkgname = screencfg-git +pkgname = screencfg diff --git a/pkg/arch/grm-git/.SRCINFO b/pkg/arch/grm-git/.SRCINFO new file mode 100644 index 0000000..2f2f5e9 --- /dev/null +++ b/pkg/arch/grm-git/.SRCINFO @@ -0,0 +1,17 @@ +pkgbase = screencfg-git + pkgdesc = Automatically configure your screen setup + pkgver = 0.1.r0.g1e8bf1d + pkgrel = 1 + url = https://github.com/hakoerber/screencfg + arch = x86_64 + license = GPL-3.0-only + makedepends = cargo + makedepends = git + depends = glibc + depends = gcc-libs + provides = screencfg + conflicts = screencfg + source = screencfg-git::git+https://github.com/hakoerber/screencfg#branch=master + sha256sums = SKIP + +pkgname = screencfg-git diff --git a/pkg/arch/grm-git/.gitignore b/pkg/arch/grm-git/.gitignore new file mode 100644 index 0000000..a980af0 --- /dev/null +++ b/pkg/arch/grm-git/.gitignore @@ -0,0 +1,4 @@ +* +!/.gitignore +!/PKGBUILD +!/.SRCINFO diff --git a/pkg/arch/PKGBUILD b/pkg/arch/grm-git/PKGBUILD similarity index 100% rename from pkg/arch/PKGBUILD rename to pkg/arch/grm-git/PKGBUILD diff --git a/pkg/arch/local/.SRCINFO b/pkg/arch/local/.SRCINFO new file mode 100644 index 0000000..49a0d83 --- /dev/null +++ b/pkg/arch/local/.SRCINFO @@ -0,0 +1,13 @@ +pkgbase = screencfg + pkgdesc = Automatically configure your screen setup + pkgver = 0.1.r0.g1e8bf1d + pkgrel = 1 + url = https://github.com/hakoerber/screencfg + arch = x86_64 + license = GPL-3.0-only + makedepends = cargo + makedepends = git + depends = glibc + depends = gcc-libs + +pkgname = screencfg diff --git a/pkg/arch/local/.gitignore b/pkg/arch/local/.gitignore new file mode 100644 index 0000000..a980af0 --- /dev/null +++ b/pkg/arch/local/.gitignore @@ -0,0 +1,4 @@ +* +!/.gitignore +!/PKGBUILD +!/.SRCINFO diff --git a/pkg/arch/local/PKGBUILD b/pkg/arch/local/PKGBUILD new file mode 100644 index 0000000..7a82462 --- /dev/null +++ b/pkg/arch/local/PKGBUILD @@ -0,0 +1,40 @@ +# Maintainer: Hannes Körber +pkgname='screencfg' +pkgver=0.1.r0.g1e8bf1d +pkgrel=1 +pkgdesc='Automatically configure your screen setup' +arch=('x86_64') +url='https://github.com/hakoerber/screencfg' +license=('GPL-3.0-only') +depends=('glibc' 'gcc-libs') +makedepends=('cargo' 'git') +source=() +sha256sums=() + +prepare() { + cd "../../../.." + pwd + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" +} + +build() { + cd "../../../.." + pwd + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo build --frozen --release +} + +check() { + cd "../../../.." + pwd + export RUSTUP_TOOLCHAIN=stable + cargo test --frozen +} + +package() { + pwd + cd "../../../.." + install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/${pkgname/-git}" +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cb830a0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,23 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use super::Error; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Config { + pub post_commands: Option>, +} + +pub(crate) fn from_path(path: &Path) -> Result, Error> { + let content = match std::fs::read_to_string(path) { + Ok(p) => p, + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => return Ok(None), + _ => return Err(Error::ConfigFileOpen(e)), + }, + }; + + Ok(Some(toml::from_str(&content)?)) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..b3a6e75 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,104 @@ +use std::{fmt, io, path::PathBuf, string}; + +#[derive(Debug)] +pub(crate) enum Msg { + Owned(String), + Static(&'static str), +} + +impl From<&'static str> for Msg { + fn from(value: &'static str) -> Self { + Self::Static(value) + } +} + +impl From for Msg { + fn from(value: String) -> Self { + Self::Owned(value) + } +} + +impl fmt::Display for Msg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Msg::Owned(ref s) => s.as_str(), + Msg::Static(s) => s, + } + ) + } +} + +#[derive(Debug)] +pub(crate) enum Error { + Generic(Msg), + Command(Msg), + Classify(Msg), + Workstation(Msg), + Plan(Msg), + Apply(Msg), + I3(i3::Error), + Xrandr(xrandr::Error), + InvalidSetup(Msg), + InvalidConfig(Msg), + ConfigFileOpen(io::Error), + ConfigNotFound { path: PathBuf }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match *self { + Self::Generic(ref msg) => format!("error: {msg}"), + Self::Command(ref msg) => format!("command failed: {msg}"), + Self::Classify(ref msg) => format!("classification failed: {msg}"), + Self::Workstation(ref msg) => format!("workstation failed: {msg}"), + Self::Plan(ref msg) => format!("plan failed: {msg}"), + Self::Apply(ref msg) => format!("apply failed: {msg}"), + Self::I3(ref e) => format!("i3: {e}"), + Self::Xrandr(ref e) => format!("xrandr: {e}"), + Self::InvalidSetup(ref msg) => format!("invalid setup: {msg}"), + Self::InvalidConfig(ref msg) => format!("invalid config: {msg}"), + Self::ConfigFileOpen(ref err) => format!("could not open config: {err}"), + Self::ConfigNotFound { ref path } => + format!("could not find config file at {}", path.display()), + }, + ) + } +} + +impl From for Error { + fn from(value: fmt::Error) -> Self { + Self::Generic(Msg::Owned(value.to_string())) + } +} + +impl From for Error { + fn from(value: i3::Error) -> Self { + Self::I3(value) + } +} + +impl From for Error { + fn from(value: xrandr::Error) -> Self { + Self::Xrandr(value) + } +} + +impl From for Error { + fn from(value: string::FromUtf8Error) -> Self { + Self::Command(Msg::Owned(value.to_string())) + } +} + +impl From for Error { + fn from(value: toml::de::Error) -> Self { + Self::InvalidConfig(Msg::Owned(value.to_string())) + } +} + +impl std::error::Error for Error {} diff --git a/src/i3.rs b/src/i3.rs deleted file mode 100644 index c53d51f..0000000 --- a/src/i3.rs +++ /dev/null @@ -1,407 +0,0 @@ -use std::ffi::OsStr; -use std::fmt; -use std::io::{self, Read, Write}; -use std::ops::{Deref, DerefMut, Index}; -use std::os::unix::ffi::OsStrExt as _; -use std::os::unix::net; -use std::path::PathBuf; -use std::process; -use std::time::Duration; - -#[derive(Debug)] -pub enum Error { - Connection(String), - Command(String), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}", - match self { - Error::Connection(msg) => format!("connection failed: {msg}"), - Error::Command(msg) => format!("command failed: {msg}"), - } - ) - } -} - -impl From for Error { - fn from(value: io::Error) -> Self { - Self::Command(value.to_string()) - } -} - -impl From for Error { - fn from(value: serde_json::Error) -> Self { - Self::Connection(value.to_string()) - } -} - -impl std::error::Error for Error {} - -pub struct Connection(net::UnixStream); - -#[derive(Clone)] -pub enum Command { - Nop, - MoveWorkspace { id: usize, output: Output }, -} - -impl From for String { - fn from(value: Command) -> Self { - match value { - Command::Nop => "nop".to_string(), - Command::MoveWorkspace { id, output } => { - format!( - "[workspace=\"{id}\"] move workspace to output {}", - output.name - ) - } - } - } -} - -impl Connection { - pub fn version(&mut self) -> Result { - Message::Version.send(self)?; - - let response = Response::read(self)?; - - match response { - Response::Version(version) => Ok(version.into()), - _ => Err(Error::Connection( - "received invalid response from i3".into(), - )), - } - } - - pub fn outputs(&mut self) -> Result { - Message::Outputs.send(self)?; - let response = Response::read(self)?; - - match response { - Response::Outputs(outputs) => Ok(outputs.into()), - _ => Err(Error::Connection( - "received invalid response from i3".into(), - )), - } - } - - pub fn workspaces(&mut self) -> Result { - Message::Workspaces.send(self)?; - - let response = Response::read(self)?; - - match response { - Response::Workspaces(workspaces) => Ok(workspaces.into()), - _ => Err(Error::Connection( - "received invalid response from i3".into(), - )), - } - } - - pub fn command(&mut self, command: Command) -> Result<(), Error> { - Message::Command(command).send(self)?; - - let response = Response::read(self)?; - match response { - Response::Command(commands) => { - for payload in commands { - if !payload.success { - return Err(Error::Command( - payload.error.unwrap_or_else(|| "unknown error".into()), - )); - } - } - Ok(()) - } - _ => Err(Error::Connection( - "received invalid response from i3".into(), - )), - } - } -} - -fn get_socketpath() -> Result { - let cmd = process::Command::new("i3") - .arg("--get-socketpath") - .output()?; - - let bytes = cmd - .stdout - .into_iter() - .take_while(|c| *c != b'\n') - .collect::>(); - - let string = OsStr::from_bytes(&bytes); - - let path = PathBuf::from(string); - - Ok(path) -} - -pub fn connect() -> Result { - let socketpath = get_socketpath()?; - - let socket = net::SocketAddr::from_pathname(socketpath)?; - - let stream = net::UnixStream::connect_addr(&socket)?; - stream.set_read_timeout(Some(Duration::from_millis(100)))?; - - Ok(Connection(stream)) -} - -#[derive(Debug, serde::Deserialize)] -struct OutputPayload { - name: String, - active: bool, - primary: bool, -} - -#[derive(Clone, Debug)] -pub struct Output { - name: String, - active: bool, - primary: bool, -} - -impl fmt::Display for Output { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.name)?; - if self.active { - write!(f, " [active]")?; - } - if self.primary { - write!(f, " [primary]")?; - } - Ok(()) - } -} - -impl From for Output { - fn from(value: OutputPayload) -> Self { - Self { - name: value.name, - active: value.active, - primary: value.primary, - } - } -} - -#[derive(Debug)] -pub struct Workspaces(Vec); - -impl From> for Workspaces { - fn from(value: Vec) -> Self { - Self(value.into_iter().map(Into::into).collect()) - } -} - -impl IntoIterator for Workspaces { - type Item = Workspace; - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() - } -} - -#[derive(Debug)] -pub struct Outputs(Vec); - -impl From> for Outputs { - fn from(value: Vec) -> Self { - Self( - value - .into_iter() - .filter(|output| output.name != "xroot-0") - .map(Into::into) - .collect(), - ) - } -} - -impl IntoIterator for Outputs { - type Item = Output; - type IntoIter = as IntoIterator>::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() - } -} - -impl Deref for Outputs { - type Target = [Output]; - - fn deref(&self) -> &[Output] { - &self.0[..] - } -} -impl DerefMut for Outputs { - fn deref_mut(&mut self) -> &mut [Output] { - &mut self.0[..] - } -} - -impl Index for Outputs { - type Output = Output; - - fn index(&self, index: usize) -> &Self::Output { - &self.0[index] - } -} - -enum Message { - Command(Command), - Workspaces, - Outputs, - Version, -} - -impl From for u32 { - fn from(value: Message) -> Self { - match value { - Message::Command(_) => 0, - Message::Workspaces => 1, - Message::Outputs => 3, - Message::Version => 7, - } - } -} - -impl Message { - fn bytes(self) -> Vec { - let payload: Option = match self { - Message::Command(ref command) => Some(command.clone().into()), - Message::Workspaces => None, - Message::Outputs => None, - Message::Version => None, - }; - - let mut message: Vec = vec![]; - let command_number: u32 = self.into(); - - message.extend_from_slice("i3-ipc".as_bytes()); - message.extend_from_slice( - &(payload.as_ref().map_or(0, |payload| payload.len()) as u32).to_ne_bytes(), - ); - message.extend_from_slice(&(command_number.to_ne_bytes())); - if let Some(payload) = payload { - message.extend_from_slice(payload.as_bytes()) - } - message - } - - fn send(self, socket: &mut Connection) -> Result<(), Error> { - let message = self.bytes(); - println!("{message:?}"); - socket.0.write_all(&message)?; - Ok(()) - } -} - -#[allow(dead_code)] -#[derive(Debug, serde::Deserialize)] -struct VersionPayload { - human_readable: String, - loaded_config_file_name: String, - major: usize, - minor: usize, - patch: usize, -} - -pub struct Version { - minor: usize, - patch: usize, - major: usize, -} - -impl fmt::Display for Version { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}.{}.{}", self.major, self.minor, self.patch) - } -} - -impl From for Version { - fn from(value: VersionPayload) -> Self { - Self { - major: value.major, - minor: value.minor, - patch: value.patch, - } - } -} - -#[derive(Debug, serde::Deserialize)] -struct WorkspacePayload { - #[allow(dead_code)] - id: usize, - num: usize, - name: String, - output: String, -} - -#[derive(Debug, serde::Deserialize)] -struct CommandPayload { - success: bool, - error: Option, -} - -#[derive(Debug)] -pub struct Workspace { - num: usize, - #[allow(dead_code)] - name: String, - output: String, -} - -impl fmt::Display for Workspace { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} on {}", self.num, self.output) - } -} - -impl From for Workspace { - fn from(value: WorkspacePayload) -> Self { - Self { - num: value.num, - name: value.name, - output: value.output, - } - } -} - -#[allow(dead_code)] -#[derive(Debug)] -enum Response { - Version(VersionPayload), - Workspaces(Vec), - Command(Vec), - Outputs(Vec), -} - -impl Response { - fn read(stream: &mut Connection) -> Result { - let mut response = vec![0; "i3-ipc".chars().count() + 4 + 4]; - - stream.0.read_exact(&mut response)?; - - assert_eq!(&response[0..6], "i3-ipc".as_bytes()); - let response_length = u32::from_ne_bytes(response[6..10].try_into().unwrap()); - let response_command = u32::from_ne_bytes(response[10..14].try_into().unwrap()); - - response = vec![0; response_length as usize]; - - stream.0.read_exact(&mut response)?; - - match response_command { - 0 => Ok(Response::Command(serde_json::from_slice(&response)?)), - 1 => Ok(Response::Workspaces(serde_json::from_slice(&response)?)), - 3 => Ok(Response::Outputs(serde_json::from_slice(&response)?)), - 7 => Ok(Response::Version(serde_json::from_slice(&response)?)), - _ => return Err(Error::Connection("unknown response type".into())), - } - } -} diff --git a/src/main.rs b/src/main.rs index 3955640..e0c345e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,118 +1,180 @@ -use std::fmt; -use std::io; -use std::process; -use std::string; +use std::{ + collections::{hash_map::Entry, HashMap}, + env, fmt, + path::PathBuf, + process, +}; use clap::{Args, Parser, ValueEnum}; -mod i3; +use i3::Conn as _; -#[derive(Debug)] -enum Error { - Command(String), - Classify(String), - Workstation(String), - Plan(String), - Apply(String), - I3(i3::Error), -} +mod error; +use error::Error; -impl fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - Error::Command(msg) => format!("command failed: {msg}"), - Error::Classify(msg) => format!("classification failed: {msg}"), - Error::Workstation(msg) => format!("workstation failed: {msg}"), - Error::Plan(msg) => format!("plan failed: {msg}"), - Error::Apply(msg) => format!("apply failed: {msg}"), - Error::I3(e) => format!("i3: {e}"), - }, - ) - } -} +mod config; -impl From for Error { - fn from(value: i3::Error) -> Self { - Self::I3(value) - } -} - -impl From for Error { - fn from(value: io::Error) -> Self { - Self::Command(value.to_string()) - } -} - -impl From for Error { - fn from(value: string::FromUtf8Error) -> Self { - Self::Command(value.to_string()) - } -} - -impl std::error::Error for Error {} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum MonitorClass { +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum OutputClass { Laptop, External, } -impl TryFrom<&str> for MonitorClass { - type Error = Error; +impl fmt::Display for OutputClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match *self { + Self::Laptop => "Laptop", + Self::External => "External", + } + ) + } +} - fn try_from(value: &str) -> Result { +impl OutputClass { + fn try_detect(value: &str) -> Result { if value.starts_with("eDP-") { Ok(Self::Laptop) - } else if value.starts_with("DP-") || value.starts_with("HDMI-") { + } else if value.starts_with("DP-") + || value.starts_with("HDMI-") + || value.starts_with("DisplayPort-") + { Ok(Self::External) } else { - Err(Error::Classify(format!( - "could not classify monitor: {value}" - ))) + Err(Error::Classify( + format!("could not classify output: {value}").into(), + )) } } } -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct Monitor { +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum OutputConnectionState { + Connected, + Disconnected, +} + +impl From for OutputConnectionState { + fn from(value: xrandr::OutputState) -> Self { + match value { + xrandr::OutputState::Connected => Self::Connected, + xrandr::OutputState::Disconnected => Self::Disconnected, + } + } +} + +impl fmt::Display for OutputConnectionState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + OutputConnectionState::Connected => "connected", + OutputConnectionState::Disconnected => "disconnected", + } + ) + } +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +struct Output { + class: OutputClass, name: String, - class: MonitorClass, + connection_state: OutputConnectionState, } -impl Monitor { - fn on(self) -> MonitorSetting { - MonitorSetting { - monitor: self, - state: MonitorState::On, +impl fmt::Display for Output { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.name, self.class) + } +} + +impl<'out> Output { + fn on(&'out self) -> OutputSetting<'out> { + if self.connection_state != OutputConnectionState::Connected { + panic!("tried to activate disconnected output") + } + OutputSetting { + output: self, + state: OutputState::Connected(OutputActiveState::On), } } - fn off(self) -> MonitorSetting { - MonitorSetting { - monitor: self, - state: MonitorState::Off, + fn off(&'out self) -> OutputSetting<'out> { + let state = if self.connection_state == OutputConnectionState::Disconnected { + OutputState::Disconnected + } else { + OutputState::Connected(OutputActiveState::Off) + }; + + OutputSetting { + output: self, + state, } } + + fn findall(i3: &mut i3::Connection) -> Result, Error> { + let i3_outputs = i3 + .outputs()? + .into_iter() + .map(TryInto::try_into) + .collect::, Error>>()?; + + let xrandr_outputs = xrandr::Output::findall()? + .into_iter() + .map(TryInto::try_into) + .collect::, Error>>()?; + + // TODO: do this better, without cloning name + + let mut outputs: HashMap = HashMap::from_iter( + i3_outputs + .into_iter() + .map(|output| (output.name.clone(), output)), + ); + + for xrandr_output in xrandr_outputs { + match outputs.entry(xrandr_output.name.clone()) { + Entry::Occupied(existing) => { + let i3_connection_state = &existing.get().connection_state; + if i3_connection_state != &xrandr_output.connection_state { + return Err(Error::Generic( + format!( + "connection state mismatch, i3:{}, xrandr:{}", + i3_connection_state, xrandr_output.connection_state, + ) + .into(), + )); + } + } + Entry::Vacant(entry) => { + entry.insert(xrandr_output); + } + } + } + + Ok(outputs.into_values().collect()) + } } #[derive(Debug, PartialEq, Eq)] -struct Workstation { - laptop: Option, - externals: Option<(Monitor, Vec)>, +struct Workstation<'out> { + laptop: Option<&'out Output>, + externals: Option<(&'out Output, Vec<&'out Output>)>, + disconnected_externals: Vec<&'out Output>, } -impl TryFrom> for Workstation { +impl<'out> TryFrom<&'out [Output]> for Workstation<'out> { type Error = Error; - fn try_from(mut value: Vec) -> Result { - value.sort(); + fn try_from(value: &'out [Output]) -> Result { + let (mut laptops, mut non_laptops): (Vec<_>, Vec<_>) = value + .iter() + .partition(|output| output.class == OutputClass::Laptop); - let (mut laptops, non_laptops): (Vec<_>, Vec<_>) = value - .into_iter() - .partition(|monitor| monitor.class == MonitorClass::Laptop); + non_laptops.sort(); let laptop = match laptops.len() { 0 => None, @@ -124,12 +186,16 @@ impl TryFrom> for Workstation { } }; - let (mut externals, rest): (Vec<_>, Vec<_>) = non_laptops + let (connected_externals, disconnected_externals): (Vec<_>, Vec<_>) = non_laptops .into_iter() - .partition(|monitor| monitor.class == MonitorClass::External); + .partition(|output| output.connection_state == OutputConnectionState::Connected); + + let (mut externals, rest): (Vec<_>, Vec<_>) = connected_externals + .into_iter() + .partition(|output| output.class == OutputClass::External); if laptop.is_none() && externals.is_empty() { - return Err(Error::Workstation("no screens found".to_string())); + return Err(Error::Workstation("no screens found".into())); } let externals = match externals.len() { @@ -137,274 +203,516 @@ impl TryFrom> for Workstation { _ => Some((externals.remove(0), externals)), }; - assert_eq!(rest.len(), 0); + if !rest.is_empty() { + return Err(Error::Generic( + "screens that are neither External nor Laptop found".into(), + )); + } - Ok(Self { laptop, externals }) + Ok(Self { + laptop, + externals, + disconnected_externals, + }) } } -#[derive(Debug, PartialEq, Eq)] -enum MonitorState { +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum OutputActiveState { On, Off, } -#[derive(Debug, PartialEq, Eq)] -struct MonitorSetting { - monitor: Monitor, - state: MonitorState, -} - -#[derive(Debug, PartialEq, Eq)] -struct Plan { - monitors: Vec, -} - -impl Plan { - fn command(&self) -> (String, Vec) { - let mut args = vec![]; - - let mut left: Option<&MonitorSetting> = None; - - for monitor in &self.monitors { - args.push("--output".into()); - args.push(monitor.monitor.name.clone()); - - match monitor.state { - MonitorState::On => { - args.push("--auto".into()); - if let Some(left) = left { - args.push("--right-of".into()); - args.push(left.monitor.name.clone()); - } - left = Some(monitor); - } - MonitorState::Off => args.push("--off".into()), - }; - } - - ("xrandr".into(), args) - } - - fn apply(self) -> Result<(String, Vec), Error> { - let (program, args) = self.command(); - let output = process::Command::new(&program).args(&args).output()?; - - let _ = output - .status - .success() - .then_some(()) - .ok_or(Error::Apply(String::from_utf8(output.stderr)?)); - - Ok((program, args)) - } -} - -impl fmt::Display for Plan { +impl fmt::Display for OutputActiveState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let active_monitors = self - .monitors + write!( + f, + "{}", + match self { + Self::On => "on", + Self::Off => "off", + } + ) + } +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum OutputState { + Connected(OutputActiveState), + Disconnected, +} + +impl fmt::Display for OutputState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match *self { + Self::Connected(ref active_state) => format!("connected({})", active_state), + Self::Disconnected => "disconnected".to_owned(), + } + ) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct OutputSetting<'out> { + output: &'out Output, + state: OutputState, +} + +impl fmt::Display for OutputSetting<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "output {} to {}", self.output, self.state) + } +} + +#[derive(Debug)] +struct Workspaces<'out>(Vec>); + +impl<'out> Workspaces<'out> { + fn convert(workspaces: i3::Workspaces, outputs: &[&'out Output]) -> Result { + Ok(Self( + workspaces + .into_iter() + .map(|from| { + Ok(Workspace { + num: from.num, + name: from.name, + output: outputs + .iter() + .find(|output| from.output == output.name) + .ok_or_else(|| { + Error::Generic( + format!( + "output of workspace {} ({}) not found in i3 outputs", + from.num, from.output + ) + .into(), + ) + })?, + }) + }) + .collect::, Error>>()?, + )) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct Workspace<'out> { + num: usize, + name: String, + output: &'out Output, +} + +impl fmt::Display for Workspace<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {} on {}", self.num, self.name, self.output) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct WorkspaceSetting<'ws, 'out> { + workspace: &'ws Workspace<'out>, + output: &'out Output, +} + +impl fmt::Display for WorkspaceSetting<'_, '_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "workspace {} to output {}", self.workspace, self.output) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct Plan<'ws, 'out> { + output_settings: Vec>, + workspace_settings: Vec>, +} + +impl fmt::Display for Plan<'_, '_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for output in &self.output_settings { + write!(f, "{output}")?; + } + for workspace in &self.workspace_settings { + write!(f, "{workspace}")?; + } + Ok(()) + } +} + +#[derive(Debug)] +enum Command<'out, 'args> { + Xrandr(String, Vec<&'args str>), + MoveWorkspace { num: usize, output: &'out Output }, +} + +impl fmt::Display for Command<'_, '_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::Xrandr(ref program, ref args) => write!(f, "{} {}", program, args.join(" ")), + Self::MoveWorkspace { num, output } => { + write!(f, "move workspace {num} to {}", output.name) + } + } + } +} + +impl<'ws, 'out> Plan<'ws, 'out> { + fn diagram(&self, f: &mut impl fmt::Write) -> fmt::Result { + let active_outputs = self + .output_settings .iter() - .filter(|monitor| monitor.state == MonitorState::On) + .filter(|setting| setting.state == OutputState::Connected(OutputActiveState::On)) .collect::>(); - let padding_top = active_monitors + let padding_top = active_outputs .iter() - .map(|monitor| "─".repeat(monitor.monitor.name.len())) + .map(|setting| "─".repeat(setting.output.name.len())) .collect::>() .join("─┬─"); - let padding_bottom = active_monitors + let padding_bottom = active_outputs .iter() - .map(|monitor| "─".repeat(monitor.monitor.name.len())) + .map(|setting| "─".repeat(setting.output.name.len())) .collect::>() .join("─┴─"); writeln!( f, "┌{}┐\n┆ ╭─{padding_top}─╮ ┆", - "┄".repeat(padding_top.chars().count() + 2 * 2 + 2) + "┄".repeat( + padding_top + .chars() + .count() + .checked_add((2 * 2) + 2) + .expect("width overflowed") + ) )?; writeln!( f, "┆ │ {} │ ┆", - active_monitors + active_outputs .into_iter() - .map(|monitor| monitor.monitor.name.clone()) + .map(|setting| setting.output.name.as_str()) .collect::>() .join(" │ ") )?; write!( f, "┆ ╰─{padding_bottom}─╯ ┆\n└{}┘", - "┄".repeat(padding_bottom.chars().count() + 2 * 2 + 2) + "┄".repeat( + padding_bottom + .chars() + .count() + .checked_add(2 * 2 + 2) + .expect("width overflowed") + ) )?; Ok(()) } + + fn commands(&self) -> Vec> { + let mut commands = vec![]; + let mut args = vec![]; + + let mut left: Option<&OutputSetting> = None; + + for setting in &self.output_settings { + args.push("--output"); + args.push(&setting.output.name); + + match setting.state { + OutputState::Connected(OutputActiveState::On) => { + args.push("--auto"); + if let Some(left) = left { + args.push("--right-of"); + args.push(&left.output.name); + } + left = Some(setting); + } + OutputState::Connected(OutputActiveState::Off) | OutputState::Disconnected => { + args.push("--off") + } + }; + } + + commands.push(Command::Xrandr("xrandr".into(), args)); + + for setting in &self.workspace_settings { + let from = &setting.workspace.output; + let to = &setting.output; + + assert_ne!( + from, to, + "moving workspace to its current location, logic error" + ); + + commands.push(Command::MoveWorkspace { + num: setting.workspace.num, + output: to, + }); + } + + commands + } + + fn apply(self, i3: &mut i3::Connection) -> Result>, Error> { + let commands = self.commands(); + for command in &commands { + match *command { + Command::Xrandr(ref program, ref args) => { + let output = process::Command::new(program) + .args(args) + .output() + .map_err(|e| Error::Command(e.to_string().into()))?; + + output + .status + .success() + .then_some(()) + .ok_or(Error::Apply(String::from_utf8(output.stderr)?.into()))?; + } + Command::MoveWorkspace { num, output } => { + i3.command(i3::Command::MoveWorkspace { + id: num, + output: output.name.clone(), + })?; + } + } + } + + // apply the workspace moves again. this may be necessary because i3 auto-assigns a new workspace + // when activating a new output. this new workspace may actually belong to a different output. + for command in &commands { + if let Command::MoveWorkspace { num, output } = command { + i3.command(i3::Command::MoveWorkspace { + id: *num, + output: output.name.clone(), + })?; + } + } + + Ok(commands) + } } -impl Workstation { - fn plan(&self, setup: Setup) -> Result { - match setup { - Setup::LaptopLeft => match self.laptop { - None => Err(Error::Plan("no laptop screen found".to_string())), - Some(ref laptop) => Ok(Plan { - monitors: { - let mut v = vec![laptop.clone().on()]; - v.append({ - &mut match &self.externals { - None => { - return Err(Error::Plan( - "no external screens found".to_string(), - )) - } - Some((ext, rest)) => { - let mut v = vec![ext.clone().on()]; - v.append( - &mut rest.iter().map(|ext| ext.clone().on()).collect(), - ); - v - } - } - }); - v - }, - }), +impl<'ws, 'out> Workstation<'out> { + fn all_on_laptop( + workspaces: &'ws Workspaces<'out>, + laptop: &'out Output, + externals: Option<(&'out Output, Vec<&'out Output>)>, + disconnected_externals: Vec<&'out Output>, + ) -> Plan<'ws, 'out> { + Plan { + output_settings: { + let mut outputs = vec![laptop.on()]; + outputs.append({ + &mut match externals { + None => vec![], + Some((ext, rest)) => { + let mut v = vec![ext.off()]; + v.append(&mut rest.iter().map(|ext| ext.off()).collect()); + v + } + } + }); + outputs.extend( + disconnected_externals + .into_iter() + .map(|output| output.off()), + ); + outputs }, - Setup::LaptopRight => match self.laptop { - None => Err(Error::Plan("no laptop screen found".to_string())), - Some(ref laptop) => Ok(Plan { - monitors: { - let mut v = match &self.externals { - None => { - return Err(Error::Plan("no external screens found".to_string())) + workspace_settings: workspaces + .0 + .iter() + .filter(|workspace| (workspace.output != laptop)) + .map(|workspace| WorkspaceSetting { + workspace, + output: laptop, + }) + .collect(), + } + } + + fn all_on_external( + workspaces: &'ws Workspaces<'out>, + laptop: Option<&'out Output>, + externals: &(&'out Output, Vec<&'out Output>), + disconnected_externals: Vec<&'out Output>, + ) -> Result, Error> { + Ok(Plan { + output_settings: { + let mut v = { + let mut v = vec![externals.0.on()]; + v.append(&mut externals.1.iter().map(|output| output.on()).collect()); + v + }; + if let Some(laptop) = laptop { + v.push(laptop.off()); + } + v.extend( + disconnected_externals + .into_iter() + .map(|output| output.off()), + ); + v + }, + workspace_settings: { + let mut v = vec![]; + for workspace in &workspaces.0 { + let target_output = match workspace.num { + 1..=5 => externals.0, + 6..=10 => match externals.1.len() { + 0 => externals.0, + 1 => externals.1.first().expect("checked for len() above"), + _ => { + return Err(Error::InvalidSetup( + "more than 2 external monitors not supported".into(), + )) } - Some((ext, rest)) => { - let mut v = vec![ext.clone().on()]; - v.append(&mut rest.iter().map(|ext| ext.clone().on()).collect()); - v - } - }; - v.push(laptop.clone().on()); - v + }, + _ => { + return Err(Error::InvalidSetup( + "only workspaces between 1 and 10 are supported".into(), + )) + } + }; + if workspace.output != target_output { + v.push(WorkspaceSetting { + workspace, + output: target_output, + }); + } + } + v + }, + }) + } + + fn distribute_workspaces( + workspaces: &'ws Workspaces<'out>, + laptop: &'out Output, + externals: &(&'out Output, Vec<&'out Output>), + ) -> Result>, Error> { + let mut v = vec![]; + for workspace in &workspaces.0 { + let target_output = match workspace.num { + 7..=10 => laptop, + i @ 1..=6 => match externals.1.len() { + 0 => externals.0, + 1 => match i { + 1..=3 => externals.0, + 4..=6 => externals.1.first().expect("checked for len() above"), + _ => unreachable!("checked the range above"), }, - }), + _ => { + return Err(Error::InvalidSetup( + "more than 2 external monitors not supported".into(), + )) + } + }, + _ => { + return Err(Error::InvalidSetup( + "only workspaces between 1 and 10 are supported".into(), + )) + } + }; + if workspace.output != target_output { + v.push(WorkspaceSetting { + workspace, + output: target_output, + }); + } + } + Ok(v) + } + + fn plan( + &self, + setup: Setup, + workspaces: &'ws Workspaces<'out>, + ) -> Result, Error> { + match setup { + setup @ (Setup::LaptopLeft | Setup::LaptopRight) => match self.laptop { + None => Err(Error::Plan("no laptop screen found".into())), + Some(laptop) => { + let Some(ref externals) = self.externals else { + return Err(Error::Plan("no external screens found".into())); + }; + let workspace_settings = + Self::distribute_workspaces(workspaces, laptop, externals)?; + + let mut output_settings = vec![externals.0.on()]; + output_settings.append(&mut externals.1.iter().map(|ext| ext.on()).collect()); + output_settings.extend( + self.disconnected_externals + .iter() + .map(|output| output.off()), + ); + + match setup { + Setup::LaptopLeft => output_settings.insert(0, laptop.on()), + Setup::LaptopRight => output_settings.push(laptop.on()), + Setup::LaptopOnly | Setup::ExternalOnly => { + unreachable!("checked for enum values above") + } + } + + Ok(Plan { + output_settings, + workspace_settings, + }) + } }, Setup::LaptopOnly => match self.laptop { - None => Err(Error::Plan("no laptop screen found".to_string())), - Some(ref laptop) => Ok(Plan { - monitors: { - let mut v = vec![laptop.clone().on()]; - v.append({ - &mut match &self.externals { - None => vec![], - Some((ext, rest)) => { - let mut v = vec![ext.clone().off()]; - v.append( - &mut rest.iter().map(|ext| ext.clone().off()).collect(), - ); - v - } - } - }); - v - }, - }), + None => Err(Error::Plan("no laptop screen found".into())), + Some(laptop) => Ok(Self::all_on_laptop( + workspaces, + laptop, + self.externals.clone(), + self.disconnected_externals.clone(), + )), }, - Setup::ExternalOnly => match &self.externals { - None => Err(Error::Plan("no external screens found".to_string())), - Some((ext, rest)) => Ok(Plan { - monitors: { - let mut v = { - let mut v = vec![ext.clone().on()]; - v.append( - &mut rest.iter().map(|monitor| monitor.clone().on()).collect(), - ); - v - }; - if let Some(laptop) = &self.laptop { - v.push(laptop.clone().off()); - } - v - }, - }), + Setup::ExternalOnly => match self.externals { + None => Err(Error::Plan("no external screens found".into())), + Some(ref externals) => Ok(Self::all_on_external( + workspaces, + self.laptop, + externals, + self.disconnected_externals.clone(), + )?), }, } } } -#[derive(Debug, PartialEq, Eq)] -enum OutputState { - Connected, - Disconnected, -} - -impl TryFrom<&str> for OutputState { +impl TryFrom for Output { type Error = Error; - fn try_from(value: &str) -> Result { - match value { - "connected" => Ok(Self::Connected), - "disconnected" => Ok(Self::Disconnected), - _ => Err(Error::Command(format!( - "unknown xrandr output state: {value}" - ))), - } - } -} + fn try_from(value: i3::Output) -> Result { + let class = OutputClass::try_detect(&value.name)?; -struct Output { - name: String, - state: OutputState, -} - -impl Monitor { - fn new(name: &str) -> Result { Ok(Self { - name: name.into(), - class: name.try_into()?, + name: value.name, + class, + // all outputs detected by i3 are implicitly connected + connection_state: OutputConnectionState::Connected, }) } +} - fn findall() -> Result, Error> { - String::from_utf8( - process::Command::new("xrandr") - .arg("--query") - .output()? - .stdout, - )? - .lines() - .skip(1) // skip header - .filter(|line| { - if let Some(c) = line.chars().next() { - c.is_alphabetic() - } else { - false - } - }) - .map(|line| { - let mut parts = line.split_whitespace(); - match (parts.next(), parts.next()) { - (Some(part_1), Some(part_2)) => Ok(Output { - name: part_1.to_string(), - state: part_2.try_into()?, - }), - _ => Err(Error::Command(format!( - "not enough output information in line: {line}" - ))), - } - }) - .filter(|result| { - result - .as_ref() - .map(|output| output.state == OutputState::Connected) - .unwrap_or(true) - }) - .flat_map(|result| result.map(|output| Self::new(&output.name))) - .collect::, Error>>() - .map(|mut monitors| { - monitors.sort(); - monitors +impl TryFrom for Output { + type Error = Error; + + fn try_from(value: xrandr::Output) -> Result { + let class = OutputClass::try_detect(&value.name)?; + + Ok(Self { + name: value.name, + class, + connection_state: value.state.into(), }) } } @@ -438,74 +746,146 @@ struct Cli { #[arg(long)] diagram: bool, + + #[arg(long)] + debug: bool, + + #[arg(long)] + config: Option, } -#[allow(unreachable_code)] +const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME"; + +#[expect(clippy::print_stdout, reason = "main")] fn run() -> Result<(), Error> { + let args = Cli::parse(); + + let config = match args.config { + Some(path) => { + let path = PathBuf::from(path); + match config::from_path(&path)? { + Some(c) => Ok(Some(c)), + None => Err(Error::ConfigNotFound { path }), + } + } + None => { + let mut config_home = match env::var(XDG_CONFIG_HOME) { + Ok(v) => Ok(PathBuf::from(v)), + Err(e) => match e { + env::VarError::NotPresent => match env::var("HOME") { + Ok(v) => Ok([&v, ".config"].iter().collect::()), + Err(e) => match e { + env::VarError::NotPresent => Err(Error::Generic("HOME not set".into())), + env::VarError::NotUnicode(_) => { + Err(Error::Generic("HOME contains invalid unicode".into())) + } + }, + }, + env::VarError::NotUnicode(_) => Err(Error::Generic( + "{XDG_CONFIG_HOME} env variable is not unicode".into(), + )), + }, + }?; + config_home.push("screencfg.toml"); + Ok(config::from_path(&config_home)?) + } + }?; + let mut i3_connection = i3::connect()?; - let version = i3_connection.version()?; + let outputs = Output::findall(&mut i3_connection)?; - println!("i3 version: {version}"); + if args.debug { + println!("i3 outputs:"); + for output in &outputs { + println!(" - {output}"); + } + println!(); + } + + let workstation: Workstation = (&*outputs).try_into()?; let workspaces = i3_connection.workspaces()?; + let workspaces = Workspaces::convert(workspaces, &outputs.iter().collect::>())?; - println!("i3 workspaces:"); - for workspace in workspaces { - println!("- {workspace}"); + if args.debug { + println!("i3 workspaces:"); + for workspace in &workspaces.0 { + println!(" - {workspace}"); + } + println!(); } i3_connection.command(i3::Command::Nop)?; - let outputs = i3_connection.outputs()?; - - println!("i3 outputs:"); - for output in outputs.iter() { - println!("- {output}") - } - - // i3_connection.command(i3::Command::MoveWorkspace { - // id: 3, - // output: outputs[0], - // })?; - - return Ok(()); - - let args = Cli::parse(); - - let monitors = Monitor::findall()?; - let workstation: Workstation = monitors.try_into()?; - let plan = if let Some(setup) = args.approach.setup { - workstation.plan(setup)? + workstation.plan(setup, &workspaces)? } else { workstation - .plan(Setup::LaptopLeft) - .or_else(|_| workstation.plan(Setup::LaptopOnly)) - .or_else(|_| workstation.plan(Setup::ExternalOnly)) - .map_err(|_| Error::Plan("no plan fit with \"best\" strategy".to_string()))? + .plan(Setup::LaptopLeft, &workspaces) + .or_else(|_| workstation.plan(Setup::LaptopOnly, &workspaces)) + .or_else(|_| workstation.plan(Setup::ExternalOnly, &workspaces)) + .map_err(|_| Error::Plan("no plan fit with \"best\" strategy".into()))? }; - if args.diagram { + if args.debug { println!("{plan}"); + println!(); + } + if args.diagram { + let mut buf = String::new(); + plan.diagram(&mut buf)?; + + println!("{buf}"); + println!(); } - let command = if args.dry_run { - plan.command() + let commands = if args.dry_run { + plan.commands() } else { - plan.apply()? + plan.apply(&mut i3_connection)? }; - println!("{} {}", command.0, command.1.join(" ")); + println!("applying changes:"); + for command in commands { + println!("- {command}"); + } + + if let Some(post_commands) = config.and_then(|c| c.post_commands) { + for command in post_commands { + println!("executing post command \"{command}\""); + let output = process::Command::new("bash") + .arg("-c") + .arg(&command) + .output() + .map_err(|e| { + Error::Generic( + format!("post command \"{command}\" invocation failed: {e}").into(), + ) + })?; + + if !output.status.success() { + return Err(Error::Generic( + format!( + "post command \"{command}\" failed: {stderr}", + stderr = String::from_utf8(output.stderr) + .unwrap_or_else(|_| "stderr invalid utf8".to_owned()) + ) + .into(), + )); + } + } + } Ok(()) } +#[allow(clippy::print_stderr, reason = "main")] fn main() -> process::ExitCode { process::ExitCode::from(match run() { Ok(()) => 0, Err(e) => { - println!("{e}"); + eprintln!("{e}"); 1 } }) @@ -515,27 +895,38 @@ fn main() -> process::ExitCode { mod tests { use super::*; - enum PlanExpect<'a> { + enum PlanExpect<'cmd, 'ws, 'out> { Error, - Valid(Plan, &'a str), + Valid(Plan<'ws, 'out>, &'cmd str), } #[test] fn single_laptop() -> Result<(), Error> { - let laptop = Monitor { - name: "eDP-1".to_string(), - class: MonitorClass::Laptop, + let mut connection = i3::MockConnection { + fail: false, + setting: i3::MockSetting::LaptopOnly, }; - let monitors = vec![laptop.clone()]; + let mut outputs = connection + .outputs()? + .into_iter() + .map(TryInto::try_into) + .collect::, Error>>()?; + outputs.sort(); - let workstation: Workstation = monitors.try_into()?; + let workspaces = Workspaces::convert( + connection.workspaces()?, + &outputs.iter().collect::>(), + )?; + + let workstation: Workstation = outputs[..].try_into()?; assert_eq!( &workstation, &Workstation { - laptop: Some(laptop.clone()), - externals: None + laptop: Some(&outputs[0]), + externals: None, + disconnected_externals: vec![], } ); @@ -546,19 +937,32 @@ mod tests { Setup::LaptopOnly, PlanExpect::Valid( Plan { - monitors: vec![laptop.on()], + output_settings: vec![outputs[0].on()], + workspace_settings: vec![], }, "--output eDP-1 --auto", ), ), (Setup::ExternalOnly, PlanExpect::Error), ] { - let result = workstation.plan(setup); + let result = workstation.plan(setup, &workspaces); match expect { PlanExpect::Error => assert!(result.is_err()), PlanExpect::Valid(plan, cmd) => { assert_eq!(result?, plan); - assert_eq!(plan.command().1.join(" "), cmd); + assert_eq!( + plan.commands() + .into_iter() + .filter_map(|cmd| { + match cmd { + Command::Xrandr(_cmd, args) => Some(args.join(" ")), + Command::MoveWorkspace { .. } => None, + } + }) + .next() + .unwrap(), + cmd + ); } } } @@ -568,19 +972,21 @@ mod tests { #[test] fn multiple_laptops() -> Result<(), Error> { - let laptop1 = Monitor { + let laptop1 = Output { name: "eDP-1".to_string(), - class: MonitorClass::Laptop, + class: OutputClass::Laptop, + connection_state: OutputConnectionState::Connected, }; - let laptop2 = Monitor { + let laptop2 = Output { name: "eDP-2".to_string(), - class: MonitorClass::Laptop, + class: OutputClass::Laptop, + connection_state: OutputConnectionState::Connected, }; - let monitors = vec![laptop1, laptop2]; + let outputs = [laptop1, laptop2]; - let workstation: Result = monitors.try_into(); + let workstation: Result = outputs[..].try_into(); assert!(workstation.is_err()); @@ -589,9 +995,9 @@ mod tests { #[test] fn no_screens() -> Result<(), Error> { - let monitors = vec![]; + let outputs = []; - let workstation: Result = monitors.try_into(); + let workstation: Result = outputs[..].try_into(); assert!(workstation.is_err()); @@ -600,20 +1006,31 @@ mod tests { #[test] fn single_external() -> Result<(), Error> { - let external = Monitor { - name: "DP-1".to_string(), - class: MonitorClass::External, + let mut connection = i3::MockConnection { + fail: false, + setting: i3::MockSetting::ExternalOnly(1), }; - let monitors = vec![external.clone()]; + let mut outputs = connection + .outputs()? + .into_iter() + .map(TryInto::try_into) + .collect::, Error>>()?; + outputs.sort(); - let workstation: Workstation = monitors.try_into()?; + let workspaces = Workspaces::convert( + connection.workspaces()?, + &outputs.iter().collect::>(), + )?; + + let workstation: Workstation = outputs[..].try_into()?; assert_eq!( workstation, Workstation { laptop: None, - externals: Some((external.clone(), vec![])) + externals: Some((&outputs[0], vec![])), + disconnected_externals: vec![], } ); @@ -625,18 +1042,31 @@ mod tests { Setup::ExternalOnly, PlanExpect::Valid( Plan { - monitors: vec![external.on()], + output_settings: vec![outputs[0].on()], + workspace_settings: vec![], }, "--output DP-1 --auto", ), ), ] { - let result = workstation.plan(setup); + let result = workstation.plan(setup, &workspaces); match expect { PlanExpect::Error => assert!(result.is_err()), PlanExpect::Valid(plan, cmd) => { - assert_eq!(result?, plan); - assert_eq!(plan.command().1.join(" "), cmd); + assert_eq!(result?.output_settings, plan.output_settings); + assert_eq!( + plan.commands() + .into_iter() + .filter_map(|cmd| { + match cmd { + Command::Xrandr(_cmd, args) => Some(args.join(" ")), + Command::MoveWorkspace { .. } => None, + } + }) + .next() + .unwrap(), + cmd + ); } } } @@ -646,31 +1076,31 @@ mod tests { #[test] fn multiple_external() -> Result<(), Error> { - let external1 = Monitor { - name: "DP-1".to_string(), - class: MonitorClass::External, - }; - let external2 = Monitor { - name: "DP-2".to_string(), - class: MonitorClass::External, - }; - let external3 = Monitor { - name: "DP-3".to_string(), - class: MonitorClass::External, + let mut connection = i3::MockConnection { + fail: false, + setting: i3::MockSetting::ExternalOnly(2), }; - let monitors = vec![external2.clone(), external3.clone(), external1.clone()]; + let mut outputs = connection + .outputs()? + .into_iter() + .map(TryInto::try_into) + .collect::, Error>>()?; + outputs.sort(); - let workstation: Workstation = monitors.try_into()?; + let workspaces = Workspaces::convert( + connection.workspaces()?, + &outputs.iter().collect::>(), + )?; + + let workstation: Workstation = outputs[..].try_into()?; assert_eq!( workstation, Workstation { laptop: None, - externals: Some(( - external1.clone(), - vec![external2.clone(), external3.clone()] - )) + externals: Some((&outputs[0], vec![&outputs[1]])), + disconnected_externals: vec![], } ); @@ -682,22 +1112,31 @@ mod tests { Setup::ExternalOnly, PlanExpect::Valid( Plan { - monitors: vec![ - external1.clone().on(), - external2.clone().on(), - external3.on(), - ], + output_settings: vec![outputs[0].on(), outputs[1].on()], + workspace_settings: vec![], }, - "--output DP-1 --auto --output DP-2 --auto --right-of DP-1 --output DP-3 --auto --right-of DP-2", + "--output DP-1 --auto --output DP-2 --auto --right-of DP-1", ), ), ] { - let result = workstation.plan(setup); + let result = workstation.plan(setup, &workspaces); match expect { PlanExpect::Error => assert!(result.is_err()), PlanExpect::Valid(plan, cmd) => { - assert_eq!(result?, plan); - assert_eq!(plan.command().1.join(" "), cmd); + assert_eq!(result?.output_settings, plan.output_settings); + assert_eq!( + plan.commands() + .into_iter() + .filter_map(|cmd| { + match cmd { + Command::Xrandr(_cmd, args) => Some(args.join(" ")), + Command::MoveWorkspace { .. } => None, + } + }) + .next() + .unwrap(), + cmd + ); } } } @@ -707,30 +1146,31 @@ mod tests { #[test] fn mixture() -> Result<(), Error> { - let external1 = Monitor { - name: "DP-1".to_string(), - class: MonitorClass::External, + let mut connection = i3::MockConnection { + fail: false, + setting: i3::MockSetting::Mixed, }; - let external2 = Monitor { - name: "DP-2".to_string(), - class: MonitorClass::External, - }; + let mut outputs = connection + .outputs()? + .into_iter() + .map(TryInto::try_into) + .collect::, Error>>()?; + outputs.sort(); - let laptop = Monitor { - name: "eDP-1".to_string(), - class: MonitorClass::Laptop, - }; + let workspaces = Workspaces::convert( + connection.workspaces()?, + &outputs.iter().collect::>(), + )?; - let monitors = vec![external2.clone(), laptop.clone(), external1.clone()]; - - let workstation: Workstation = monitors.try_into()?; + let workstation: Workstation = outputs[..].try_into()?; assert_eq!( workstation, Workstation { - laptop: Some(laptop.clone()), - externals: Some((external1.clone(), vec![external2.clone()])) + laptop: Some(&outputs[0]), + externals: Some((&outputs[1], vec![&outputs[2]])), + disconnected_externals: vec![], } ); @@ -739,61 +1179,79 @@ mod tests { Setup::LaptopLeft, PlanExpect::Valid( Plan { - monitors: vec![ - laptop.clone().on(), - external1.clone().on(), - external2.clone().on(), + output_settings: vec![ + outputs[0].on(), + outputs[1].on(), + outputs[2].on(), ], + workspace_settings:vec![], }, - "--output eDP-1 --auto --output DP-1 --auto --right-of eDP-1 --output DP-2 --auto --right-of DP-1", + "--output eDP-1 --auto --output DP-1 --auto --right-of eDP-1 --output HDMI-1 --auto --right-of DP-1", ), ), ( Setup::LaptopRight, PlanExpect::Valid( Plan { - monitors: vec![ - external1.clone().on(), - external2.clone().on(), - laptop.clone().on(), + output_settings: vec![ + outputs[1].on(), + outputs[2].on(), + outputs[0].on(), ], + workspace_settings:vec![], }, - "--output DP-1 --auto --output DP-2 --auto --right-of DP-1 --output eDP-1 --auto --right-of DP-2", + "--output DP-1 --auto --output HDMI-1 --auto --right-of DP-1 --output eDP-1 --auto --right-of HDMI-1", ), ), ( Setup::LaptopOnly, PlanExpect::Valid( Plan { - monitors: vec![ - laptop.clone().on(), - external1.clone().off(), - external2.clone().off(), + output_settings: vec![ + outputs[0].on(), + outputs[1].off(), + outputs[2].off(), ], + workspace_settings:vec![], }, - "--output eDP-1 --auto --output DP-1 --off --output DP-2 --off", + "--output eDP-1 --auto --output DP-1 --off --output HDMI-1 --off", ), ), ( Setup::ExternalOnly, PlanExpect::Valid( Plan { - monitors: vec![ - external1.clone().on(), - external2.clone().on(), - laptop.clone().off(), + output_settings: vec![ + outputs[1].on(), + outputs[2].on(), + outputs[0].off(), ], + workspace_settings:vec![], }, - "--output DP-1 --auto --output DP-2 --auto --right-of DP-1 --output eDP-1 --off", + "--output DP-1 --auto --output HDMI-1 --auto --right-of DP-1 --output eDP-1 --off", ), ), ] { - let result = workstation.plan(setup); + let result = workstation.plan(setup, &workspaces); match expect { PlanExpect::Error => assert!(result.is_err()), PlanExpect::Valid(plan, cmd) => { - assert_eq!(result?, plan); - assert_eq!(plan.command().1.join(" "), cmd); + assert_eq!(result?.output_settings, plan.output_settings); + assert_eq!( + plan.commands() + .into_iter() + .filter_map(|cmd| { + match cmd { + Command::Xrandr(_cmd, args) => { + Some(args.join(" ")) + } + Command::MoveWorkspace { .. } => None, + } + }) + .next() + .unwrap(), + cmd + ); } } } diff --git a/xrandr/Cargo.toml b/xrandr/Cargo.toml new file mode 100644 index 0000000..c4e826c --- /dev/null +++ b/xrandr/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xrandr" +version = "0.1.0" +edition = "2021" + +[dependencies] + +[lints] +workspace = true diff --git a/xrandr/src/error.rs b/xrandr/src/error.rs new file mode 100644 index 0000000..f7109cc --- /dev/null +++ b/xrandr/src/error.rs @@ -0,0 +1,65 @@ +use std::{fmt, io, string}; + +#[derive(Debug)] +pub enum Msg { + Owned(String), + Static(&'static str), +} + +impl From<&'static str> for Msg { + fn from(value: &'static str) -> Self { + Self::Static(value) + } +} + +impl From for Msg { + fn from(value: String) -> Self { + Self::Owned(value) + } +} + +impl fmt::Display for Msg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match *self { + Self::Owned(ref s) => s.as_str(), + Self::Static(s) => s, + } + ) + } +} + +#[derive(Debug)] +pub enum Error { + Command(Msg), + Parse(Msg), +} + +impl From for Error { + fn from(value: io::Error) -> Self { + Self::Command(Msg::Owned(value.to_string())) + } +} + +impl From for Error { + fn from(value: string::FromUtf8Error) -> Self { + Self::Parse(Msg::Owned(value.to_string())) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match *self { + Self::Command(ref msg) => format!("command failed: {msg}"), + Self::Parse(ref msg) => format!("parsing command output failed: {msg}"), + } + ) + } +} + +impl std::error::Error for Error {} diff --git a/xrandr/src/lib.rs b/xrandr/src/lib.rs new file mode 100644 index 0000000..8ecc20f --- /dev/null +++ b/xrandr/src/lib.rs @@ -0,0 +1,57 @@ +use std::{process, string::String}; + +mod error; + +pub use error::Error; + +#[derive(Debug, PartialEq, Eq)] +pub enum OutputState { + Connected, + Disconnected, +} + +impl TryFrom<&str> for OutputState { + type Error = Error; + + fn try_from(value: &str) -> Result { + match value { + "connected" => Ok(Self::Connected), + "disconnected" => Ok(Self::Disconnected), + _ => Err(Error::Parse( + format!("unknown xrandr output state: {value}").into(), + )), + } + } +} + +pub struct Output { + pub name: String, + pub state: OutputState, +} + +impl Output { + pub fn findall() -> Result, Error> { + String::from_utf8( + process::Command::new("xrandr") + .arg("--query") + .output()? + .stdout, + )? + .lines() + .skip(1) // skip header + .filter(|line| line.chars().next().map_or(false, char::is_alphanumeric)) + .map(|line| { + let mut parts = line.split_whitespace(); + match (parts.next(), parts.next()) { + (Some(part_1), Some(part_2)) => Ok(Self { + name: part_1.to_owned(), + state: part_2.try_into()?, + }), + _ => Err(Error::Command( + format!("not enough output information in line: {line}").into(), + )), + } + }) + .collect() + } +}