@@ -266,7 +266,7 @@ impl App { |
| 266 | 266 | return; |
| 267 | 267 | } |
| 268 | 268 | |
| 269 | | - // Update monitor positions from profile |
| 269 | + // Update monitor positions and settings from profile |
| 270 | 270 | for state in view.monitors_mut() { |
| 271 | 271 | if let Some(config) = config_map.get(state.info.name.as_str()) { |
| 272 | 272 | // Sanity check: don't apply positions that are clearly wrong |
@@ -287,6 +287,27 @@ impl App { |
| 287 | 287 | state.info.name |
| 288 | 288 | ); |
| 289 | 289 | } |
| 290 | + |
| 291 | + // Apply mode dimensions and scaling from profile |
| 292 | + state.mode_width = config.width; |
| 293 | + state.mode_height = config.height; |
| 294 | + state.refresh = config.refresh; |
| 295 | + state.rotation = config.rotation; |
| 296 | + state.scale = config.scale; |
| 297 | + state.enabled = config.enabled; |
| 298 | + |
| 299 | + // Update visual rect to effective (scaled) dimensions |
| 300 | + let (rot_w, rot_h) = match config.rotation { |
| 301 | + 90 | 270 => (config.height, config.width), |
| 302 | + _ => (config.width, config.height), |
| 303 | + }; |
| 304 | + if (config.scale - 1.0).abs() < 0.001 { |
| 305 | + state.info.rect.width = rot_w; |
| 306 | + state.info.rect.height = rot_h; |
| 307 | + } else { |
| 308 | + state.info.rect.width = (rot_w as f64 / config.scale).round() as u32; |
| 309 | + state.info.rect.height = (rot_h as f64 / config.scale).round() as u32; |
| 310 | + } |
| 290 | 311 | } |
| 291 | 312 | } |
| 292 | 313 | |
@@ -581,6 +602,59 @@ impl App { |
| 581 | 602 | } |
| 582 | 603 | } |
| 583 | 604 | |
| 605 | + /// Refit the window to the current screen after a display change. |
| 606 | + /// Uses root window geometry (which reflects the effective screen size after |
| 607 | + /// transforms/scaling) rather than monitor detection (which returns raw mode sizes). |
| 608 | + fn refit_window(&mut self) { |
| 609 | + // Sync to ensure screen resize has been processed by the X server |
| 610 | + let _ = self.conn.sync(); |
| 611 | + |
| 612 | + // Query root window geometry for actual effective screen dimensions |
| 613 | + let (scr_w, scr_h) = match self.conn.inner().get_geometry(self.conn.root()) { |
| 614 | + Ok(cookie) => match cookie.reply() { |
| 615 | + Ok(geom) => (geom.width as u32, geom.height as u32), |
| 616 | + Err(_) => return, |
| 617 | + }, |
| 618 | + Err(_) => return, |
| 619 | + }; |
| 620 | + |
| 621 | + let win_w = WINDOW_WIDTH.min(scr_w.saturating_sub(40)); |
| 622 | + let win_h = WINDOW_HEIGHT.min(scr_h.saturating_sub(40)); |
| 623 | + let x = (scr_w as i32 - win_w as i32) / 2; |
| 624 | + let y = (scr_h as i32 - win_h as i32) / 2; |
| 625 | + |
| 626 | + tracing::info!( |
| 627 | + "refit_window: screen={}x{}, requesting window {}x{} at ({}, {})", |
| 628 | + scr_w, scr_h, win_w, win_h, x, y |
| 629 | + ); |
| 630 | + |
| 631 | + if let Err(e) = self.window.set_geometry(Rect::new(x, y, win_w, win_h)) { |
| 632 | + tracing::warn!("failed to refit window: {}", e); |
| 633 | + return; |
| 634 | + } |
| 635 | + |
| 636 | + // Flush and sync so the WM processes the configure request |
| 637 | + let _ = self.conn.flush(); |
| 638 | + let _ = self.conn.sync(); |
| 639 | + |
| 640 | + // Query ACTUAL window geometry (WM may have adjusted our request) |
| 641 | + let (actual_w, actual_h) = match self.conn.inner().get_geometry(self.window.id()) { |
| 642 | + Ok(cookie) => match cookie.reply() { |
| 643 | + Ok(geom) => (geom.width as u32, geom.height as u32), |
| 644 | + Err(_) => (win_w, win_h), |
| 645 | + }, |
| 646 | + Err(_) => (win_w, win_h), |
| 647 | + }; |
| 648 | + |
| 649 | + tracing::info!( |
| 650 | + "refit_window: actual window size after WM = {}x{}", |
| 651 | + actual_w, actual_h |
| 652 | + ); |
| 653 | + |
| 654 | + // Update renderer and layout to match actual window size |
| 655 | + self.handle_resize(Size::new(actual_w, actual_h)); |
| 656 | + } |
| 657 | + |
| 584 | 658 | /// Update the cursor based on monitor view state. |
| 585 | 659 | fn update_cursor(&mut self) { |
| 586 | 660 | let shape = self.monitor_view.cursor_shape(); |
@@ -635,11 +709,13 @@ impl App { |
| 635 | 709 | output.map(|o| o.modes.len()).unwrap_or(0) |
| 636 | 710 | ); |
| 637 | 711 | |
| 712 | + // Pass mode_width/mode_height (raw resolution) to display panel so |
| 713 | + // the resolution dropdown shows actual hardware modes, not scaled values. |
| 638 | 714 | self.display_panel.set_selected_monitor( |
| 639 | 715 | Some(&state.info.name), |
| 640 | 716 | output, |
| 641 | | - state.info.rect.width, |
| 642 | | - state.info.rect.height, |
| 717 | + state.mode_width, |
| 718 | + state.mode_height, |
| 643 | 719 | state.refresh, |
| 644 | 720 | state.rotation, |
| 645 | 721 | state.scale, |
@@ -656,9 +732,6 @@ impl App { |
| 656 | 732 | let randr = self.randr.as_ref()?; |
| 657 | 733 | let outputs = randr.get_outputs().ok()?; |
| 658 | 734 | |
| 659 | | - // Capture current DPI scale |
| 660 | | - let current_scale = dpi::get_current_scale(); |
| 661 | | - |
| 662 | 735 | Some( |
| 663 | 736 | outputs |
| 664 | 737 | .iter() |
@@ -666,6 +739,11 @@ impl App { |
| 666 | 739 | .map(|o| { |
| 667 | 740 | let mode = o.current_mode.as_ref().unwrap(); |
| 668 | 741 | let pos = o.position.unwrap_or((0, 0)); |
| 742 | + // Read per-monitor scale from CRTC transform |
| 743 | + let scale = o |
| 744 | + .crtc |
| 745 | + .map(|c| randr.get_crtc_scale(c)) |
| 746 | + .unwrap_or(1.0); |
| 669 | 747 | MonitorConfig { |
| 670 | 748 | name: o.name.clone(), |
| 671 | 749 | enabled: true, |
@@ -674,8 +752,8 @@ impl App { |
| 674 | 752 | width: mode.width as u32, |
| 675 | 753 | height: mode.height as u32, |
| 676 | 754 | refresh: mode.refresh, |
| 677 | | - scale: current_scale, // Capture current DPI scale |
| 678 | | - rotation: 0, // TODO: capture actual rotation |
| 755 | + scale, |
| 756 | + rotation: 0, // TODO: capture actual rotation |
| 679 | 757 | } |
| 680 | 758 | }) |
| 681 | 759 | .collect(), |
@@ -698,6 +776,8 @@ impl App { |
| 698 | 776 | self.pre_change_config = self.capture_current_randr_state(); |
| 699 | 777 | |
| 700 | 778 | // Build MonitorConfig from current view state |
| 779 | + // Use mode_width/mode_height (raw hardware resolution) for the RandR mode, |
| 780 | + // not info.rect which holds effective (scaled) dimensions. |
| 701 | 781 | let primary_name = self.monitor_view.primary_name().map(|s| s.to_string()); |
| 702 | 782 | let configs: Vec<MonitorConfig> = self |
| 703 | 783 | .monitor_view |
@@ -708,22 +788,21 @@ impl App { |
| 708 | 788 | enabled: state.enabled, |
| 709 | 789 | x: state.real_position.x, |
| 710 | 790 | y: state.real_position.y, |
| 711 | | - width: state.info.rect.width, |
| 712 | | - height: state.info.rect.height, |
| 791 | + width: state.mode_width, |
| 792 | + height: state.mode_height, |
| 713 | 793 | refresh: state.refresh, |
| 714 | 794 | scale: state.scale, |
| 715 | 795 | rotation: state.rotation, |
| 716 | 796 | }) |
| 717 | 797 | .collect(); |
| 718 | 798 | |
| 719 | | - // IMPORTANT: Prepare the screen size BEFORE applying any configurations |
| 720 | | - // This is essential for rotation changes which may require a larger virtual screen |
| 721 | | - if let Err(e) = randr.prepare_screen_for_configs(&configs) { |
| 722 | | - tracing::error!("failed to prepare screen size: {}", e); |
| 723 | | - self.set_status(&format!("Failed to prepare screen: {}", e)); |
| 724 | | - return; |
| 725 | | - } |
| 726 | | - |
| 799 | + // Apply CRTCs using the same approach as xrandr: |
| 800 | + // 1. Apply each CRTC (ensure_screen_size inside apply_monitor grows if needed) |
| 801 | + // 2. Shrink the screen to fit effective dimensions after all CRTCs are applied |
| 802 | + // |
| 803 | + // When scaling down (e.g., 2x), the CRTC transform reduces the scanout size |
| 804 | + // (2880x1800 mode at 2x → 1440x900 scanout), so it always fits within the |
| 805 | + // current screen. The screen is then shrunk to match after all CRTCs are set. |
| 727 | 806 | let mut success_count = 0; |
| 728 | 807 | let mut error_count = 0; |
| 729 | 808 | |
@@ -737,6 +816,13 @@ impl App { |
| 737 | 816 | } |
| 738 | 817 | } |
| 739 | 818 | |
| 819 | + // Shrink the screen to fit the effective (post-transform) dimensions. |
| 820 | + // This must happen AFTER all CRTCs are applied so the server knows the |
| 821 | + // actual scanout sizes. This matches xrandr's behavior. |
| 822 | + if let Err(e) = randr.shrink_screen_to_fit() { |
| 823 | + tracing::error!("failed to shrink screen: {}", e); |
| 824 | + } |
| 825 | + |
| 740 | 826 | // Set primary |
| 741 | 827 | if let Some(ref name) = primary_name { |
| 742 | 828 | if let Err(e) = randr.set_primary(name) { |
@@ -748,13 +834,7 @@ impl App { |
| 748 | 834 | tracing::error!("failed to flush: {}", e); |
| 749 | 835 | } |
| 750 | 836 | |
| 751 | | - // Try to shrink screen to fit (non-fatal if it fails) |
| 752 | | - if let Err(e) = randr.shrink_screen_to_fit() { |
| 753 | | - tracing::debug!("shrink_screen_to_fit failed (non-fatal): {}", e); |
| 754 | | - } |
| 755 | | - |
| 756 | 837 | // Apply DPI scaling if any monitor has non-1.0 scale |
| 757 | | - // Use the primary monitor's scale, or the first enabled monitor |
| 758 | 838 | let scale = configs |
| 759 | 839 | .iter() |
| 760 | 840 | .find(|c| c.enabled && primary_name.as_ref() == Some(&c.name)) |
@@ -764,7 +844,6 @@ impl App { |
| 764 | 844 | |
| 765 | 845 | if let Err(e) = dpi::apply_dpi_scale(scale) { |
| 766 | 846 | tracing::error!("failed to apply DPI scale: {}", e); |
| 767 | | - // Non-fatal - continue with the rest |
| 768 | 847 | } |
| 769 | 848 | |
| 770 | 849 | if error_count > 0 { |
@@ -793,7 +872,10 @@ impl App { |
| 793 | 872 | } |
| 794 | 873 | } |
| 795 | 874 | |
| 796 | | - // Show confirmation overlay |
| 875 | + // Refit window to the new screen dimensions before showing overlay |
| 876 | + self.refit_window(); |
| 877 | + |
| 878 | + // Show confirmation overlay (uses current window size after refit) |
| 797 | 879 | let size = self.window.size(); |
| 798 | 880 | let window_rect = Rect::new(0, 0, size.width, size.height); |
| 799 | 881 | self.confirm_overlay = Some(ConfirmOverlay::new(window_rect)); |
@@ -809,7 +891,10 @@ impl App { |
| 809 | 891 | watchdog::cancel_watchdog(); |
| 810 | 892 | self.watchdog_child = None; |
| 811 | 893 | |
| 812 | | - // Update original_monitors to current state |
| 894 | + // Update original_monitors to current state. |
| 895 | + // Use mode_width/mode_height (raw hardware resolution) so that revert_layout |
| 896 | + // can find the correct RandR mode. info.rect holds effective (scaled) dimensions |
| 897 | + // which may not correspond to an actual mode. |
| 813 | 898 | let primary_name = self.monitor_view.primary_name().map(|s| s.to_string()); |
| 814 | 899 | self.original_monitors = self |
| 815 | 900 | .monitor_view |
@@ -820,8 +905,8 @@ impl App { |
| 820 | 905 | rect: Rect::new( |
| 821 | 906 | state.real_position.x, |
| 822 | 907 | state.real_position.y, |
| 823 | | - state.info.rect.width, |
| 824 | | - state.info.rect.height, |
| 908 | + state.mode_width, |
| 909 | + state.mode_height, |
| 825 | 910 | ), |
| 826 | 911 | primary: primary_name.as_ref() == Some(&state.info.name), |
| 827 | 912 | width_mm: state.info.width_mm, |
@@ -829,6 +914,7 @@ impl App { |
| 829 | 914 | }) |
| 830 | 915 | .collect(); |
| 831 | 916 | |
| 917 | + self.refit_window(); |
| 832 | 918 | self.set_status("Display settings confirmed"); |
| 833 | 919 | self.monitor_view.clear_dirty(); |
| 834 | 920 | } |
@@ -843,25 +929,15 @@ impl App { |
| 843 | 929 | |
| 844 | 930 | if let Some(ref configs) = self.pre_change_config.take() { |
| 845 | 931 | if let Some(ref randr) = self.randr { |
| 846 | | - // IMPORTANT: Prepare screen size BEFORE reverting |
| 847 | | - // The pre-change config may have different dimensions than current |
| 848 | | - if let Err(e) = randr.prepare_screen_for_configs(configs) { |
| 849 | | - tracing::error!("failed to prepare screen for revert: {}", e); |
| 850 | | - // Continue anyway - we still want to try to revert |
| 851 | | - } |
| 852 | | - |
| 932 | + // Apply CRTCs (ensure_screen_size inside grows if needed), then shrink |
| 853 | 933 | for config in configs { |
| 854 | 934 | if let Err(e) = randr.apply_monitor(config) { |
| 855 | 935 | tracing::error!("failed to revert {}: {}", config.name, e); |
| 856 | 936 | } |
| 857 | 937 | } |
| 938 | + let _ = randr.shrink_screen_to_fit(); |
| 858 | 939 | let _ = randr.flush(); |
| 859 | 940 | |
| 860 | | - // Try to shrink screen to fit |
| 861 | | - if let Err(e) = randr.shrink_screen_to_fit() { |
| 862 | | - tracing::debug!("shrink_screen_to_fit failed during revert: {}", e); |
| 863 | | - } |
| 864 | | - |
| 865 | 941 | // Restore DPI scale from pre-change config |
| 866 | 942 | let scale = configs |
| 867 | 943 | .iter() |
@@ -876,6 +952,7 @@ impl App { |
| 876 | 952 | |
| 877 | 953 | // Reset view to original monitors |
| 878 | 954 | self.monitor_view.set_monitors(self.original_monitors.clone()); |
| 955 | + self.refit_window(); |
| 879 | 956 | self.set_status("Display settings reverted"); |
| 880 | 957 | } |
| 881 | 958 | |
@@ -891,10 +968,12 @@ impl App { |
| 891 | 968 | // Restore original monitors in view |
| 892 | 969 | self.monitor_view.set_monitors(self.original_monitors.clone()); |
| 893 | 970 | |
| 894 | | - // Apply via RandR |
| 971 | + // Apply via RandR using disable→resize→apply pattern |
| 895 | 972 | if let Some(ref randr) = self.randr { |
| 896 | | - for m in &self.original_monitors { |
| 897 | | - let config = MonitorConfig { |
| 973 | + let configs: Vec<MonitorConfig> = self |
| 974 | + .original_monitors |
| 975 | + .iter() |
| 976 | + .map(|m| MonitorConfig { |
| 898 | 977 | name: m.name.clone(), |
| 899 | 978 | enabled: true, |
| 900 | 979 | x: m.rect.x, |
@@ -904,12 +983,16 @@ impl App { |
| 904 | 983 | refresh: 60.0, |
| 905 | 984 | scale: 1.0, |
| 906 | 985 | rotation: 0, |
| 907 | | - }; |
| 986 | + }) |
| 987 | + .collect(); |
| 908 | 988 | |
| 909 | | - if let Err(e) = randr.apply_monitor(&config) { |
| 910 | | - tracing::error!("failed to revert {}: {}", m.name, e); |
| 989 | + // Apply CRTCs (ensure_screen_size inside grows if needed), then shrink |
| 990 | + for config in &configs { |
| 991 | + if let Err(e) = randr.apply_monitor(config) { |
| 992 | + tracing::error!("failed to revert {}: {}", config.name, e); |
| 911 | 993 | } |
| 912 | 994 | } |
| 995 | + let _ = randr.shrink_screen_to_fit(); |
| 913 | 996 | |
| 914 | 997 | // Restore primary |
| 915 | 998 | if let Some(m) = self.original_monitors.iter().find(|m| m.primary) { |
@@ -921,6 +1004,7 @@ impl App { |
| 921 | 1004 | let _ = randr.flush(); |
| 922 | 1005 | } |
| 923 | 1006 | |
| 1007 | + self.refit_window(); |
| 924 | 1008 | self.set_status("Reverted to original layout"); |
| 925 | 1009 | } |
| 926 | 1010 | |
@@ -934,21 +1018,21 @@ impl App { |
| 934 | 1018 | fn save_profile(&mut self) { |
| 935 | 1019 | let primary_name = self.monitor_view.primary_name().map(|s| s.to_string()); |
| 936 | 1020 | |
| 937 | | - // Build profile from current layout |
| 1021 | + // Build profile from current layout using raw mode dimensions |
| 938 | 1022 | let monitors: Vec<MonitorConfig> = self |
| 939 | 1023 | .monitor_view |
| 940 | 1024 | .monitors() |
| 941 | 1025 | .iter() |
| 942 | 1026 | .map(|state| MonitorConfig { |
| 943 | 1027 | name: state.info.name.clone(), |
| 944 | | - enabled: true, |
| 1028 | + enabled: state.enabled, |
| 945 | 1029 | x: state.real_position.x, |
| 946 | 1030 | y: state.real_position.y, |
| 947 | | - width: state.info.rect.width, |
| 948 | | - height: state.info.rect.height, |
| 949 | | - refresh: 60.0, |
| 950 | | - scale: 1.0, |
| 951 | | - rotation: 0, |
| 1031 | + width: state.mode_width, |
| 1032 | + height: state.mode_height, |
| 1033 | + refresh: state.refresh, |
| 1034 | + scale: state.scale, |
| 1035 | + rotation: state.rotation, |
| 952 | 1036 | }) |
| 953 | 1037 | .collect(); |
| 954 | 1038 | |
@@ -984,14 +1068,14 @@ impl App { |
| 984 | 1068 | .iter() |
| 985 | 1069 | .map(|state| MonitorConfig { |
| 986 | 1070 | name: state.info.name.clone(), |
| 987 | | - enabled: true, |
| 1071 | + enabled: state.enabled, |
| 988 | 1072 | x: state.real_position.x, |
| 989 | 1073 | y: state.real_position.y, |
| 990 | | - width: state.info.rect.width, |
| 991 | | - height: state.info.rect.height, |
| 992 | | - refresh: 60.0, |
| 993 | | - scale: 1.0, |
| 994 | | - rotation: 0, |
| 1074 | + width: state.mode_width, |
| 1075 | + height: state.mode_height, |
| 1076 | + refresh: state.refresh, |
| 1077 | + scale: state.scale, |
| 1078 | + rotation: state.rotation, |
| 995 | 1079 | }) |
| 996 | 1080 | .collect(); |
| 997 | 1081 | |