From 89bf2fbf44f2e081dd79f88f60bdce22481bdcc6 Mon Sep 17 00:00:00 2001 From: shubh-bruno Date: Mon, 23 Feb 2026 16:32:34 +0530 Subject: [PATCH] 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 --- .../Preferences/Display/Zoom/StyledWrapper.js | 120 ++++++++++++++++++ .../Preferences/Display/Zoom/index.js | 112 ++++++++++++++++ .../components/Preferences/Display/index.js | 4 + packages/bruno-common/src/index.ts | 1 + packages/bruno-common/src/zoom/index.ts | 5 + .../bruno-electron/src/app/menu-template.js | 24 +++- packages/bruno-electron/src/index.js | 75 ++++++++++- .../bruno-electron/src/store/preferences.js | 9 ++ 8 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 packages/bruno-app/src/components/Preferences/Display/Zoom/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/Preferences/Display/Zoom/index.js create mode 100644 packages/bruno-common/src/zoom/index.ts diff --git a/packages/bruno-app/src/components/Preferences/Display/Zoom/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Display/Zoom/StyledWrapper.js new file mode 100644 index 000000000..c21a2b3dc --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/Display/Zoom/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/Preferences/Display/Zoom/index.js b/packages/bruno-app/src/components/Preferences/Display/Zoom/index.js new file mode 100644 index 000000000..e06f7a9b6 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/Display/Zoom/index.js @@ -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 ( + +
+
+ +
setIsOpen(!isOpen)}> + {selectedOption?.label} + +
+ {isOpen && ( +
+ {ZOOM_OPTIONS.map((option) => ( +
handleSelect(option.value)} + > + {option.label} + {option.value === savedZoom && } +
+ ))} +
+ )} +
+ {!isDefault && ( + + )} +
+
+ ); +}; + +export default Zoom; diff --git a/packages/bruno-app/src/components/Preferences/Display/index.js b/packages/bruno-app/src/components/Preferences/Display/index.js index 3f878b55e..dee94c138 100644 --- a/packages/bruno-app/src/components/Preferences/Display/index.js +++ b/packages/bruno-app/src/components/Preferences/Display/index.js @@ -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 }) => {
+
+ +
); diff --git a/packages/bruno-common/src/index.ts b/packages/bruno-common/src/index.ts index 3becb2973..a17b1d23a 100644 --- a/packages/bruno-common/src/index.ts +++ b/packages/bruno-common/src/index.ts @@ -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'; diff --git a/packages/bruno-common/src/zoom/index.ts b/packages/bruno-common/src/zoom/index.ts new file mode 100644 index 000000000..c8336dd7c --- /dev/null +++ b/packages/bruno-common/src/zoom/index.ts @@ -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); +}; diff --git a/packages/bruno-electron/src/app/menu-template.js b/packages/bruno-electron/src/app/menu-template.js index d5d05c737..fe6cbc716 100644 --- a/packages/bruno-electron/src/app/menu-template.js +++ b/packages/bruno-electron/src/app/menu-template.js @@ -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' } ] diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 24f72e21d..a3402a3e1 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -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); +} diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index 1fc04b5f3..19b82dec5 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -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); },