diff --git a/ivette/src/dome/src/main/menubar.ts b/ivette/src/dome/src/main/menubar.ts index 292cfffe70f7b787086b66501026aefe504fc654..5ca2f7ed903e25445a82f67b147a64e0c08c913c 100644 --- a/ivette/src/dome/src/main/menubar.ts +++ b/ivette/src/dome/src/main/menubar.ts @@ -162,6 +162,16 @@ const editMenuItems: MenuSpec = [ accelerator: 'CmdOrCtrl+A', role: 'selectAll', }, + Separator, + { + label: 'Find', + accelerator: 'CmdOrCtrl+F', + click: ( + _item: Electron.MenuItem, + window: Electron.BrowserWindow, + _evt: Electron.KeyboardEvent, + ) => window.webContents.send('dome.ipc.find'), + }, ]; // -------------------------------------------------------------------------- @@ -326,8 +336,13 @@ export function addMenuItem(custom: CustomMenuItemSpec) { } if (entry.spec) Object.assign(entry.spec, spec); if (entry.item) Object.assign(entry.item, spec); - } else { + if (!spec.click && !spec.role) + spec.click = ( + _item: Electron.MenuItem, + window: Electron.BrowserWindow, + _evt: Electron.KeyboardEvent, + ) => window.webContents.send('dome.ipc.menu.clicked', id); customItems.set(id, { spec }); menuSpec.push(spec); } diff --git a/ivette/src/dome/src/renderer/controls/labels.tsx b/ivette/src/dome/src/renderer/controls/labels.tsx index aab6e501780f3478424ad3619c19552430ff8bb7..a9250344cc81c7cf3a1f2e374fc259e711c3df64 100644 --- a/ivette/src/dome/src/renderer/controls/labels.tsx +++ b/ivette/src/dome/src/renderer/controls/labels.tsx @@ -16,6 +16,7 @@ import './style.css'; // --- Generic Label // -------------------------------------------------------------------------- +/** Labels support fowarding refs to their inner [<label/>] element. */ export interface LabelProps { /** Text of the label. Prepend to other children elements. */ label?: string; @@ -31,9 +32,13 @@ export interface LabelProps { display?: boolean; /** Additional content of the `<label/>` element. */ children?: React.ReactNode; + /** Click event callback. */ + onClick?: (evt: React.MouseEvent) => void; + /** Right-click event callback. */ + onContextMenu?: (evt: React.MouseEvent) => void; } -const makeLabel = (className: string, props: LabelProps) => { +const makeLabel = (className: string) => (props: LabelProps, ref: any) => { const { display = true } = props; const allClasses = classes( className, @@ -42,9 +47,12 @@ const makeLabel = (className: string, props: LabelProps) => { ); return ( <label + ref={ref} className={allClasses} title={props.title} style={props.style} + onClick={props.onClick} + onContextMenu={props.onContextMenu} > {props.icon && <Icon title={props.title} id={props.icon} />} {props.label} @@ -68,18 +76,18 @@ const TCODE = 'dome-xLabel dome-text-code'; // -------------------------------------------------------------------------- /** Simple labels. */ -export const Label = (props: LabelProps) => makeLabel(LABEL, props); +export const Label = React.forwardRef(makeLabel(LABEL)); /** Title and headings. */ -export const Title = (props: LabelProps) => makeLabel(TITLE, props); +export const Title = React.forwardRef(makeLabel(TITLE)); /** Description, textbook content. */ -export const Descr = (props: LabelProps) => makeLabel(DESCR, props); +export const Descr = React.forwardRef(makeLabel(DESCR)); /** Selectable textual information. */ -export const Data = (props: LabelProps) => makeLabel(TDATA, props); +export const Data = React.forwardRef(makeLabel(TDATA)); /** Selectable inlined source-code content. */ -export const Code = (props: LabelProps) => makeLabel(TCODE, props); +export const Code = React.forwardRef(makeLabel(TCODE)); // -------------------------------------------------------------------------- diff --git a/ivette/src/dome/src/renderer/dome.tsx b/ivette/src/dome/src/renderer/dome.tsx index dda75f7467022882903bab4e3ea0bd696440e2f0..b1e1d879afc1defc21759cf0ba17a932a7f41fca 100644 --- a/ivette/src/dome/src/renderer/dome.tsx +++ b/ivette/src/dome/src/renderer/dome.tsx @@ -98,13 +98,17 @@ export class Event<A = void> { } +/** Custom React Hook on event. */ export function useEvent<A>( - evt: Event<A>, + evt: undefined | null | Event<A>, callback: (arg: A) => void, ) { return React.useEffect(() => { - evt.on(callback); - return () => evt.off(callback); + if (evt) { + evt.on(callback); + return () => evt.off(callback); + } + return undefined; }); } @@ -126,9 +130,14 @@ export const update = new Event('dome.update'); It is emitted when the entire window is reloaded. */ export const reload = new Event('dome.reload'); - ipcRenderer.on('dome.ipc.reload', () => reload.emit()); +/** + Dome « Find » event. Trigered by [Cmd+F] and [Edit > Find] menu. + */ +export const find = new Event('dome.find'); +ipcRenderer.on('dome.ipc.find', () => find.emit()); + /** Command-line arguments event handler. */ export function onCommand( job: (argv: string[], workingDir: string) => void, diff --git a/ivette/src/dome/src/renderer/frame/style.css b/ivette/src/dome/src/renderer/frame/style.css index b022fbc981f08628459bfc9df3d0043611f62dd0..5ec0dd8eba070447366ccea4defb7cf1b1577ac5 100644 --- a/ivette/src/dome/src/renderer/frame/style.css +++ b/ivette/src/dome/src/renderer/frame/style.css @@ -271,7 +271,7 @@ /* Layout */ -.dome-xToolBar-Control { +.dome-xToolBar-control { flex: 0 0 auto ; height: 21px ; margin: 6px 5px 6px 5px ; @@ -282,72 +282,72 @@ outline: none ; } -.dome-xToolBar-Control svg { +.dome-xToolBar-control svg { height: 10px ; position: relative ; } -.dome-xToolBar-Control svg + label { +.dome-xToolBar-control svg + label { margin-left: 4px ; } /* Background */ -.dome-window-active .dome-xToolBar-Control { +.dome-window-active .dome-xToolBar-control { background-image: linear-gradient(to bottom, #e8e8e8 0, #f1f1f1 100%); } -.dome-window-active .dome-xToolBar-Control:hover:not(:disabled) { +.dome-window-active .dome-xToolBar-control:hover:not(:disabled) { background-color: #ffffff ; background-image: none ; } -.dome-window-active .dome-xToolBar-Control.dome-xToolBar-positive:not(:disabled) { +.dome-window-active .dome-xToolBar-control.dome-xToolBar-positive:not(:disabled) { background-image: linear-gradient(to bottom, #34ff52 0%, #48fd64 100%); } -.dome-window-active .dome-xToolBar-Control.dome-xToolBar-positive:hover:not(:disabled) { +.dome-window-active .dome-xToolBar-control.dome-xToolBar-positive:hover:not(:disabled) { background-color: #00ff00 ; background-image: none ; } -.dome-window-active .dome-xToolBar-Control.dome-xToolBar-negative:not(:disabled) { +.dome-window-active .dome-xToolBar-control.dome-xToolBar-negative:not(:disabled) { color: #ccc ; fill: #ccc ; background-image: linear-gradient(to bottom, #ec453e 0%, #ff4c47 100%); } -.dome-window-active .dome-xToolBar-Control.dome-xToolBar-negative:hover:not(:disabled) { +.dome-window-active .dome-xToolBar-control.dome-xToolBar-negative:hover:not(:disabled) { background-color: red ; background-image: none ; } -.dome-window-active .dome-xToolBar-Control.dome-xToolBar-warning:not(:disabled) { +.dome-window-active .dome-xToolBar-control.dome-xToolBar-warning:not(:disabled) { background-image: linear-gradient(to bottom, #fece72 0%, #fcaa0e 100%); } -.dome-window-active .dome-xToolBar-Control.dome-xToolBar-warning:hover:not(:disabled) { +.dome-window-active .dome-xToolBar-control.dome-xToolBar-warning:hover:not(:disabled) { background-color: orange ; background-image: none ; } -.dome-window-active .dome-xToolBar-Control.dome-xToolBar-cancel:not(:disabled) { +.dome-window-active .dome-xToolBar-control.dome-xToolBar-cancel:not(:disabled) { background-color: #c2c0c2 ; background-image: none ; } -.dome-window-active .dome-xToolBar-Control.dome-xToolBar-cancel:hover:not(:disabled) { +.dome-window-active .dome-xToolBar-control.dome-xToolBar-cancel:hover:not(:disabled) { background-image: linear-gradient(to bottom, #e8e8e8 0, #f1f1f1 100%); } -.dome-window-inactive .dome-xToolBar-Control { +.dome-window-inactive .dome-xToolBar-control { box-shadow: none ; background-image: none ; } /* Activated */ -.dome-window-active .dome-xToolBar-Control:active:not(:disabled) { +.dome-window-active .dome-xToolBar-control:active:not(:disabled) { fill: #ddd ; color: #ddd ; background-color: gray ; @@ -356,21 +356,21 @@ /* Disabled */ -.dome-window-active .dome-xToolBar-Control:disabled { +.dome-window-active .dome-xToolBar-control:disabled { fill: #ccc ; color: #ccc ; box-shadow: none ; border-color: #bbb ; } -.dome-window-inactive .dome-xToolBar-Control:disabled { +.dome-window-inactive .dome-xToolBar-control:disabled { fill: #ccc ; color: #ccc ; } /* Selected */ -.dome-window-active .dome-xToolBar-Control.dome-selected { +.dome-window-active .dome-xToolBar-control.dome-selected { fill: #fff; color: #fff; border: 1px solid transparent ; @@ -378,12 +378,12 @@ background-image: none ; } -.dome-window-active .dome-xToolBar-Control.dome-selected:hover { +.dome-window-active .dome-xToolBar-control.dome-selected:hover { background-color: #888 ; background-image: none ; } -.dome-window-inactive .dome-xToolBar-Control.dome-selected { +.dome-window-inactive .dome-xToolBar-control.dome-selected { fill: #eee; color: #eee; background-color: #ccc ; @@ -391,14 +391,14 @@ /* Selected & Disabled */ -.dome-window-active .dome-xToolBar-Control.dome-selected:disabled { +.dome-window-active .dome-xToolBar-control.dome-selected:disabled { fill: #ccc ; color: #ccc ; border: 1px solid #bbb ; background-color: #eee ; } -.dome-window-inactive .dome-xToolBar-Control.dome-selected:disabled { +.dome-window-inactive .dome-xToolBar-control.dome-selected:disabled { fill: #ccc ; color: #ccc ; border-color: #ddd ; @@ -409,33 +409,107 @@ /* --- Styling ToolBar Button Group --- */ /* -------------------------------------------------------------------------- */ -.dome-xToolBar-Group { +.dome-xToolBar-group { display: flex ; flex-direction: row ; flex-wrap: nowrap ; margin: 6px 5px 6px 5px ; } -.dome-xToolBar-Group .dome-xToolBar-Control { +.dome-xToolBar-group .dome-xToolBar-control { margin: 0 ; } -.dome-xToolBar-Group .dome-xToolBar-Control:not(:first-child) { +.dome-xToolBar-group .dome-xToolBar-control:not(:first-child) { border-left: 0 ; } -.dome-xToolBar-Group > .dome-xToolBar-Control:first-child { +.dome-xToolBar-group > .dome-xToolBar-control:first-child { border-top-right-radius: 0 ; border-bottom-right-radius: 0 ; } -.dome-xToolBar-Group > .dome-xToolBar-Control:last-child { +.dome-xToolBar-group > .dome-xToolBar-control:last-child { border-top-left-radius: 0 ; border-bottom-left-radius: 0 ; } -.dome-xToolBar-Group > .dome-xToolBar-Control:not(:first-child):not(:last-child) { +.dome-xToolBar-group > .dome-xToolBar-control:not(:first-child):not(:last-child) { border-radius: 0 ; } /* -------------------------------------------------------------------------- */ +/* --- Styling ToolBar Search Field --- */ +/* -------------------------------------------------------------------------- */ + +.dome-xToolBar-control.dome-xToolBar-searchfield { + background-image: none ; + background-color: #eee ; + margin-top: 1px ; + padding-top: 1px ; + padding-left: 20px ; + border-radius: 12px ; + border-color: #aaa ; + width: 32px ; + transition: width 0.4s ease-in-out ; +} + +.dome-window-inactive .dome-xToolBar-control.dome-xToolBar-searchfield { + background-color: #f6f6f6 ; + border-color: #ddd ; +} + +.dome-xToolBar-searchfield:focus, +.dome-xToolBar-searchfield:hover, +.dome-xToolBar-searchicon:hover + .dome-xToolBar-searchfield +{ + width: 160px; +} + +.dome-xToolBar-searchicon { + position: relative ; + overflow: visible ; + z-Index: +1; + height: 0px; + width: 0px; + top: 2px ; + left: 12px ; +} + +.dome-window-inactive .dome-xToolBar-searchicon svg { + fill: #ccc ; +} + +.dome-xToolBar-searchmenu { + position: relative ; + width: 162px ; + max-height: 120px ; + overflow-x: hidden ; + overflow-y: auto ; + left: -7px ; + top: 4px ; + padding: 0px ; + border: 1pt solid #aaa ; + background: #fff ; +} + +.dome-xToolBar-searchitem { + display: block ; + width: 100% ; + margin-left: 0px ; + margin-right: 0px ; + padding-left: 4px ; + padding-right: 2px ; +} + +.dome-xToolBar-searchitem:hover, +.dome-xToolBar-searchindex +{ + background: lightblue !important; +} + +.dome-xToolBar-searchitem:nth-child(even) { + background: #eef7f7; +} + +/* -------------------------------------------------------------------------- */ diff --git a/ivette/src/dome/src/renderer/frame/toolbars.tsx b/ivette/src/dome/src/renderer/frame/toolbars.tsx index 7ab35c1fedb18f1263b593d3e65fc1aa33c1a6f8..93d5c78ccc323d1ed613fd8350d8466248e5accf 100644 --- a/ivette/src/dome/src/renderer/frame/toolbars.tsx +++ b/ivette/src/dome/src/renderer/frame/toolbars.tsx @@ -8,16 +8,13 @@ */ import React from 'react'; +import { Event, useEvent, find } from 'dome'; +import { debounce } from 'lodash'; +import { SVG } from 'dome/controls/icons'; +import { Label } from 'dome/controls/labels'; import { classes } from 'dome/misc/utils'; - import './style.css'; -// -------------------------------------------------------------------------- -// --- ToolBar Button -// -------------------------------------------------------------------------- - -import { SVG } from 'dome/controls/icons'; - // -------------------------------------------------------------------------- // --- ToolBar Container // -------------------------------------------------------------------------- @@ -70,8 +67,8 @@ export const Separator = () => ( </div> ); -const SELECT = 'dome-xToolBar-Control dome-selected'; -const BUTTON = 'dome-xToolBar-Control dome-color-frame'; +const SELECT = 'dome-xToolBar-control dome-selected'; +const BUTTON = 'dome-xToolBar-control dome-color-frame'; const KIND = (kind: undefined | string) => ( kind ? ` dome-xToolBar-${kind}` : '' ); @@ -165,7 +162,7 @@ export function ButtonGroup<A>(props: SelectionProps<A>) { onClick: onChange, }; return ( - <div className="dome-xToolBar-Group"> + <div className="dome-xToolBar-group"> {React.Children.map(children, (elt) => React.cloneElement( elt, { ...baseProps, ...elt.props }, @@ -192,7 +189,7 @@ export function Select(props: SelectionProps<string>) { }; return ( <select - className="dome-xToolBar-Control dome-color-frame" + className="dome-xToolBar-control dome-color-frame" value={props.value} disabled={disabled || !enabled} onChange={callback} @@ -203,3 +200,154 @@ export function Select(props: SelectionProps<string>) { } // -------------------------------------------------------------------------- +// --- SearchField +// -------------------------------------------------------------------------- + +const DEBOUNCED_SEARCH = 200; + +const scrollToRef = (r: undefined | HTMLLabelElement) => { + if (r) r.scrollIntoView({ block: 'nearest' }); +}; + +export interface Hint<A> { + id: string | number; + icon?: string; + label: string | JSX.Element; + title?: string; + value: A; +} + +export interface SearchFieldProps<A> { + /** Tooltip Text. */ + title?: string; + /** Placeholder Text. */ + placeholder?: string; + /** Provided search hints (with respect to last `onSearch()` callback). */ + hints?: Hint<A>[]; + /** Search callback. Triggered on Enter Key, Escape Key or Blur event. */ + onSelect?: (pattern: string) => void; + /** Dynamic search callback. Triggered on key pressed (debounced). */ + onSearch?: (pattern: string) => void; + /** Hint selection callback. */ + onHint?: (hint: Hint<A>) => void; + /** Event that triggers a focus request (defaults to [[Dome.find]]). */ + event?: null | Event<void>; +} + +/** + Search Bar. + */ +export function SearchField<A = undefined>(props: SearchFieldProps<A>) { + const inputRef = React.useRef<HTMLInputElement | null>(null); + const blur = () => inputRef.current?.blur(); + const focus = () => inputRef.current?.focus(); + const [value, setValue] = React.useState(''); + const [index, setIndex] = React.useState(-1); + const { onHint, onSelect, onSearch, hints = [] } = props; + + // Find event trigger + useEvent(props.event ?? find, focus); + + // Lookup trigger + const triggerLookup = React.useCallback( + debounce((pattern: string) => { + if (onSearch) onSearch(pattern); + }, DEBOUNCED_SEARCH), + [onSearch], + ); + + // Blur Event + const onBlur = () => { + setValue(''); + setIndex(-1); + if (onSearch) onSearch(''); + }; + + // Key Events + const onKey = (evt: React.KeyboardEvent) => { + switch (evt.key) { + case 'Escape': + blur(); + break; + case 'Enter': + if (index >= 0 && index < hints.length) { + if (onHint) onHint(hints[index]); + } else if (onSelect) onSelect(value); + blur(); + break; + case 'ArrowUp': + if (index < 0) setIndex(hints.length - 1); + if (index > 0) setIndex(index - 1); + break; + case 'ArrowDown': + if (index < 0 && 0 < hints.length) setIndex(0); + if (0 <= index && index < hints.length - 1) setIndex(index + 1); + break; + } + }; + + // Input Events + const onChange = (evt: React.ChangeEvent<HTMLInputElement>) => { + const newValue = evt.target.value; + triggerLookup(newValue); + setIndex(-1); + setValue(newValue); + }; + + // Render Suggestions + const suggestions = hints.map((h, k) => { + const selected = k === index || hints.length === 1; + const className = classes( + 'dome-xToolBar-searchitem', + selected && 'dome-xToolBar-searchindex', + ); + return ( + <Label + ref={selected ? scrollToRef : undefined} + key={h.id} + icon={h.icon} + title={h.title} + className={className} + onClick={() => { + if (onHint) onHint(h); + blur(); + }} + > + {h.label} + </Label> + ); + }); + const haspopup = + inputRef.current === document.activeElement + && suggestions.length > 0; + const visibility = haspopup ? 'visible' : 'hidden'; + + // Render Component + return ( + <> + <div className="dome-xToolBar-searchicon"> + <SVG id="SEARCH" /> + <div + style={{ visibility }} + className="dome-xToolBar-searchmenu" + onMouseDown={(event) => event.preventDefault()} + > + {suggestions} + </div> + </div> + <input + ref={inputRef} + type="search" + title={props.title} + value={value} + placeholder={props.placeholder} + className="dome-xToolBar-control dome-xToolBar-searchfield" + onKeyUp={onKey} + onChange={onChange} + onBlur={onBlur} + /> + </> + ); +} + +// -------------------------------------------------------------------------- diff --git a/ivette/src/renderer/Application.tsx b/ivette/src/renderer/Application.tsx index b42cf0c033c5def430170b76f6b62841342310dd..8582d0b50059c08e706a4ab2498344040be99998 100644 --- a/ivette/src/renderer/Application.tsx +++ b/ivette/src/renderer/Application.tsx @@ -19,7 +19,7 @@ import * as Controller from './Controller'; import ASTview from './ASTview'; import ASTinfo from './ASTinfo'; -import Globals from './Globals'; +import Globals, { GlobalHint, useHints } from './Globals'; import Properties from './Properties'; import Locations from './Locations'; import Values from './Values'; @@ -61,6 +61,14 @@ export default (() => { Dome.useFlipSettings('frama-c.sidebar.unfold', true); const [viewbar, flipViewbar] = Dome.useFlipSettings('frama-c.viewbar.unfold', true); + const [hints, onSearchHint] = useHints(); + const [, setSelection] = States.useSelection(); + const onGlobalHint = (h: GlobalHint) => { + setSelection({ location: h.value }); + }; + const onSelectHint = () => { + if (hints.length === 1) onGlobalHint(hints[0]); + }; return ( <Vfill> @@ -74,6 +82,13 @@ export default (() => { <Controller.Control /> <HistorySelectionControls /> <Toolbar.Filler /> + <Toolbar.SearchField + placeholder="Search…" + hints={hints} + onSearch={onSearchHint} + onSelect={onSelectHint} + onHint={onGlobalHint} + /> <Toolbar.Button icon="ITEMS.GRID" title="Customize Main View" diff --git a/ivette/src/renderer/Globals.tsx b/ivette/src/renderer/Globals.tsx index 316ab76d28234bb4ffc675d244710bd3bb4c717f..4b153c03dde17807d64943188e5a4667c6a0e063 100644 --- a/ivette/src/renderer/Globals.tsx +++ b/ivette/src/renderer/Globals.tsx @@ -4,12 +4,41 @@ import React from 'react'; import { Section, Item } from 'dome/frame/sidebars'; +import type { Hint } from 'dome/frame/toolbars'; import * as States from 'frama-c/states'; import { alpha } from 'dome/data/compare'; import { functions, functionsData } from 'frama-c/api/kernel/ast'; // -------------------------------------------------------------------------- -// --- Globals Section +// --- Global Search Hints +// -------------------------------------------------------------------------- + +export type GlobalHint = Hint<States.Location>; + +const makeHint = (fct: functionsData): GlobalHint => ({ + id: fct.key, + label: fct.name, + title: fct.signature, + value: { function: fct.name }, +}); + +export function useHints(): [GlobalHint[], (pattern: string) => void] { + const fcts = States.useSyncArray(functions).getArray(); + const [hints, setHints] = React.useState<GlobalHint[]>([]); + const onSearch = (pattern: string) => { + if (pattern === '') setHints([]); + else { + const p = pattern.toLowerCase(); + setHints(fcts.filter((fn) => ( + 0 <= fn.name.toLowerCase().indexOf(p) + )).map(makeHint)); + } + }; + return [hints, onSearch]; +} + +// -------------------------------------------------------------------------- +// --- Globals Section(s) // -------------------------------------------------------------------------- export default () => {