feat: interface zoom control settings (#7255)

* feat: interface zoom control settings

* fix: allow zoom controls using shortcuts

* fix: maintain consitency in zoom shortcuts and ui interface

* fix: added min max to 50% and 150% for zoom

* fix: moved percentageToZoomLevel function in bruno-common

* chore: abstractions

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
This commit is contained in:
shubh-bruno
2026-02-23 16:32:34 +05:30
committed by GitHub
parent 04ef477f3b
commit 89bf2fbf44
8 changed files with 341 additions and 9 deletions

View File

@@ -0,0 +1,120 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.zoom-field {
min-width: 120px;
max-width: 150px;
position: relative;
}
.zoom-field label {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
display: block;
}
.custom-select {
width: 100%;
height: 2.25rem;
padding: 0 2rem 0 0.625rem;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: ${(props) => props.theme.input.background};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 0.375rem;
color: ${(props) => props.theme.text};
font-size: 0.875rem;
line-height: 1.25;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: space-between;
}
.custom-select:hover {
border-color: ${(props) => props.theme.input.hoverBorder || props.theme.input.border};
}
.custom-select .selected-value {
flex: 1;
}
.custom-select .chevron-icon {
color: ${(props) => props.theme.input.border};
flex-shrink: 0;
transition: transform 0.15s ease;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background-color: ${(props) => props.theme.input.background};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 50;
max-height: 200px;
overflow-y: auto;
}
.dropdown-option {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.1s ease;
}
.dropdown-option:hover {
background-color: ${(props) => props.theme.input.border};
}
.dropdown-option.selected {
background-color: ${(props) => props.theme.input.focusBorder || props.theme.input.border}22;
}
.dropdown-option .option-label {
flex: 1;
}
.dropdown-option .check-icon {
color: ${(props) => props.theme.textLink};
flex-shrink: 0;
}
.reset-btn {
padding: 0.5rem 1rem;
height: 2.25rem;
background: transparent;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 0.375rem;
color: ${(props) => props.theme.textLink};
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
background: ${(props) => props.theme.input.border};
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
box-shadow: 0 0 0 2px ${(props) => props.theme.input.focusBorder}33;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,112 @@
import React, { useState, useRef, useEffect } from 'react';
import get from 'lodash/get';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import { IconChevronDown, IconCheck } from '@tabler/icons';
const { percentageToZoomLevel } = require('@usebruno/common');
// Zoom options for dropdown (50% to 150%)
const ZOOM_OPTIONS = [
{ label: '50%', value: 50 },
{ label: '60%', value: 60 },
{ label: '70%', value: 70 },
{ label: '80%', value: 80 },
{ label: '90%', value: 90 },
{ label: '100%', value: 100 },
{ label: '110%', value: 110 },
{ label: '120%', value: 120 },
{ label: '130%', value: 130 },
{ label: '140%', value: 140 },
{ label: '150%', value: 150 }
];
const DEFAULT_ZOOM = 100;
const Zoom = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const dropdownRef = useRef(null);
const { ipcRenderer } = window;
// Get saved zoom percentage from Redux preferences (single source of truth)
const savedZoom = get(preferences, 'display.zoomPercentage', DEFAULT_ZOOM);
const [isOpen, setIsOpen] = useState(false);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (zoom) => {
// Apply zoom level to Electron window immediately
if (ipcRenderer) {
const zoomLevel = percentageToZoomLevel(zoom);
ipcRenderer.invoke('renderer:set-zoom-level', zoomLevel);
}
// Save to preferences via Redux (same pattern as layout)
const updatedPreferences = {
...preferences,
display: {
...get(preferences, 'display', {}),
zoomPercentage: zoom
}
};
dispatch(savePreferences(updatedPreferences));
setIsOpen(false);
};
const handleResetToDefault = () => {
handleSelect(DEFAULT_ZOOM);
};
const selectedOption = ZOOM_OPTIONS.find((opt) => opt.value === savedZoom);
const isDefault = savedZoom === DEFAULT_ZOOM;
return (
<StyledWrapper>
<div className="flex flex-row gap-4 items-end">
<div className="zoom-field" ref={dropdownRef}>
<label className="block">Interface Zoom</label>
<div className="custom-select mt-2" onClick={() => setIsOpen(!isOpen)}>
<span className="selected-value">{selectedOption?.label}</span>
<IconChevronDown size={14} className="chevron-icon" />
</div>
{isOpen && (
<div className="dropdown-menu">
{ZOOM_OPTIONS.map((option) => (
<div
key={option.value}
className={`dropdown-option ${option.value === savedZoom ? 'selected' : ''}`}
onClick={() => handleSelect(option.value)}
>
<span className="option-label">{option.label}</span>
{option.value === savedZoom && <IconCheck size={14} className="check-icon" />}
</div>
))}
</div>
)}
</div>
{!isDefault && (
<button
type="button"
className="reset-btn"
onClick={handleResetToDefault}
>
Reset
</button>
)}
</div>
</StyledWrapper>
);
};
export default Zoom;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import Font from './Font/index';
import Zoom from './Zoom/index';
const Display = ({ close }) => {
return (
@@ -9,6 +10,9 @@ const Display = ({ close }) => {
<div className="w-fit flex flex-col gap-2">
<Font close={close} />
</div>
<div className="w-full flex flex-col gap-2">
<Zoom />
</div>
</div>
</div>
);

View File

@@ -1,5 +1,6 @@
export { mockDataFunctions, timeBasedDynamicVars } from './utils/faker-functions';
export { default as interpolate, interpolateObject } from './interpolate';
export { percentageToZoomLevel } from './zoom';
export { default as isRequestTagsIncluded } from './tags';
export * as utils from './utils';

View File

@@ -0,0 +1,5 @@
// Convert percentage to zoom level (Electron uses logarithmic scale)
// Formula: percentage = 100 * 1.2^level
export const percentageToZoomLevel = (percentage: number): number => {
return Math.log(percentage / 100) / Math.log(1.2);
};

View File

@@ -62,9 +62,27 @@ const template = [
submenu: [
{ role: 'toggledevtools' },
{ type: 'separator' },
{ role: 'resetzoom' },
{ role: 'zoomin' },
{ role: 'zoomout' },
{
label: 'Actual Size',
accelerator: 'CommandOrControl+0',
click() {
ipcMain.emit('menu:reset-zoom');
}
},
{
label: 'Zoom In',
accelerator: 'CommandOrControl+Plus',
click() {
ipcMain.emit('menu:zoom-in');
}
},
{
label: 'Zoom Out',
accelerator: 'CommandOrControl+-',
click() {
ipcMain.emit('menu:zoom-out');
}
},
{ type: 'separator' },
{ role: 'togglefullscreen' }
]

View File

@@ -4,6 +4,7 @@ const { execSync } = require('node:child_process');
const isDev = require('electron-is-dev');
const os = require('os');
const { initializeShellEnv } = require('@usebruno/requests');
const { percentageToZoomLevel } = require('@usebruno/common');
if (isDev) {
if (!fs.existsSync(path.join(__dirname, '../../bruno-js/src/sandbox/bundle-browser-rollup.js'))) {
@@ -47,6 +48,7 @@ const collectionWatcher = require('./app/collection-watcher');
const WorkspaceWatcher = require('./app/workspace-watcher');
const ApiSpecWatcher = require('./app/apiSpecsWatcher');
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
const { preferencesUtil, getPreferences, savePreferences } = require('./store/preferences');
const { globalEnvironmentsManager } = require('./store/workspace-environments');
const registerNotificationsIpc = require('./ipc/notifications');
const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
@@ -92,6 +94,25 @@ const isLinux = process.platform === 'linux';
let mainWindow;
let appProtocolUrl;
// Helper function to save zoom percentage to preferences and notify renderer
const saveZoomPreferences = async (percentage) => {
if (!mainWindow) return;
const clampedPercentage = Math.max(50, Math.min(150, percentage));
const prefs = getPreferences();
prefs.display = prefs.display || {};
prefs.display.zoomPercentage = clampedPercentage;
try {
await savePreferences(prefs);
// Notify renderer to update Redux state only after successful save
mainWindow.webContents.send('main:load-preferences', prefs);
} catch (err) {
console.error('Failed to save zoom preference:', err);
}
};
// Helper function to focus and restore the main window
const focusMainWindow = () => {
if (mainWindow) {
@@ -246,17 +267,32 @@ app.on('ready', async () => {
});
ipcMain.handle('renderer:reset-zoom', () => {
mainWindow.webContents.setZoomLevel(0);
updateZoomLevel(100);
});
ipcMain.handle('renderer:zoom-in', () => {
const currentZoom = mainWindow.webContents.getZoomLevel();
mainWindow.webContents.setZoomLevel(currentZoom + 0.5);
incrementZoomAndPersist(10);
});
ipcMain.handle('renderer:zoom-out', () => {
const currentZoom = mainWindow.webContents.getZoomLevel();
mainWindow.webContents.setZoomLevel(currentZoom - 0.5);
incrementZoomAndPersist(-10);
});
// Menu event handlers for zoom (from menu-template.js)
ipcMain.on('menu:reset-zoom', () => {
updateZoomLevel(100);
});
ipcMain.on('menu:zoom-in', () => {
incrementZoomAndPersist(10);
});
ipcMain.on('menu:zoom-out', () => {
incrementZoomAndPersist(-10);
});
ipcMain.handle('renderer:set-zoom-level', (event, zoomLevel) => {
mainWindow.webContents.setZoomLevel(zoomLevel);
});
ipcMain.handle('renderer:toggle-fullscreen', () => {
@@ -282,6 +318,12 @@ app.on('ready', async () => {
});
mainWindow.once('ready-to-show', () => {
// Apply saved zoom level from preferences before showing window
const zoomPercentage = preferencesUtil.getZoomPercentage();
if (zoomPercentage) {
const zoomLevel = percentageToZoomLevel(zoomPercentage);
mainWindow.webContents.setZoomLevel(zoomLevel);
}
mainWindow.show();
});
const devPort = process.env.BRUNO_DEV_PORT || 3000;
@@ -445,7 +487,7 @@ app.on('open-file', (event, path) => {
app.on('browser-window-focus', () => {
// Quick fix for Electron issue #29996: https://github.com/electron/electron/issues/29996
globalShortcut.register('Ctrl+=', () => {
mainWindow.webContents.setZoomLevel(mainWindow.webContents.getZoomLevel() + 1);
incrementZoomAndPersist(10);
});
});
@@ -453,3 +495,24 @@ app.on('browser-window-focus', () => {
app.on('browser-window-blur', () => {
globalShortcut.unregisterAll();
});
/**
* @param {number} inc (+/- amount to zoom in / out);
*/
function incrementZoomAndPersist(inc) {
const currentPercentage = preferencesUtil.getZoomPercentage();
const nextPercentage = Math.min(
Math.max(currentPercentage + inc, 50),
150
);
updateZoomLevel(nextPercentage);
}
/**
* @param {number} percent percentage to increase or decrease zoom by, percentage is converted to chrome's log value internally
*/
function updateZoomLevel(percent) {
const zoomLevel = percentageToZoomLevel(percent);
mainWindow.webContents.setZoomLevel(zoomLevel);
saveZoomPreferences(percent);
}

View File

@@ -56,6 +56,9 @@ const defaultPreferences = {
autoSave: {
enabled: false,
interval: 1000
},
display: {
zoomPercentage: 100
}
};
@@ -110,6 +113,9 @@ const preferencesSchema = Yup.object().shape({
autoSave: Yup.object({
enabled: Yup.boolean(),
interval: Yup.number().min(100)
}),
display: Yup.object({
zoomPercentage: Yup.number().min(50).max(150)
})
});
@@ -286,6 +292,9 @@ const preferencesUtil = {
isBetaFeatureEnabled: (featureName) => {
return get(getPreferences(), `beta.${featureName}`, false);
},
getZoomPercentage: () => {
return get(getPreferences(), 'display.zoomPercentage', 100);
},
hasLaunchedBefore: () => {
return get(getPreferences(), 'onboarding.hasLaunchedBefore', false);
},