├── tui ├── .gitignore ├── media │ ├── app.png │ ├── cells.mp4 │ ├── crud.png │ ├── timer.mp4 │ ├── counter.mp4 │ ├── flight.mp4 │ ├── circle_drawer.mp4 │ └── temp_converter.mp4 ├── restart.sh ├── task │ ├── counter.go │ ├── util.go │ ├── timer.go │ ├── temperature_converter.go │ ├── cell_funcs.go │ ├── progress.go │ ├── circle_drawer.go │ ├── slider.go │ ├── crud.go │ ├── flight_booker.go │ ├── circle_canvas.go │ └── cells.go ├── go.mod ├── readme.md ├── go.sum └── main.go ├── web ├── .nvmrc ├── src │ ├── vite-env.d.ts │ ├── main.tsx │ ├── App.test.tsx │ ├── components │ │ ├── Counter.tsx │ │ ├── Counter.test.tsx │ │ ├── TemperatureConverter.test.tsx │ │ ├── TemperatureConverter.tsx │ │ ├── Timer.tsx │ │ ├── FlightBooker.tsx │ │ ├── CRUD.tsx │ │ ├── CircleDrawer.tsx │ │ └── Cells.tsx │ ├── index.css │ ├── favicon.svg │ └── App.tsx ├── tsconfig.node.json ├── postcss.config.js ├── vite.config.ts ├── .gitignore ├── index.html ├── .eslintrc.cjs ├── tailwind.config.js ├── tsconfig.json └── package.json ├── .prettierignore ├── .husky └── pre-commit ├── .prettierrc.yml ├── readme.md └── .github └── workflows └── quality.yml /tui/.gitignore: -------------------------------------------------------------------------------- 1 | tui 2 | -------------------------------------------------------------------------------- /web/.nvmrc: -------------------------------------------------------------------------------- 1 | v18.4.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | .idea 3 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tui/media/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/letientai299/7guis/HEAD/tui/media/app.png -------------------------------------------------------------------------------- /tui/media/cells.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/letientai299/7guis/HEAD/tui/media/cells.mp4 -------------------------------------------------------------------------------- /tui/media/crud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/letientai299/7guis/HEAD/tui/media/crud.png -------------------------------------------------------------------------------- /tui/media/timer.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/letientai299/7guis/HEAD/tui/media/timer.mp4 -------------------------------------------------------------------------------- /tui/media/counter.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/letientai299/7guis/HEAD/tui/media/counter.mp4 -------------------------------------------------------------------------------- /tui/media/flight.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/letientai299/7guis/HEAD/tui/media/flight.mp4 -------------------------------------------------------------------------------- /tui/media/circle_drawer.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/letientai299/7guis/HEAD/tui/media/circle_drawer.mp4 -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | cd web && pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | tabWidth: 2 2 | semi: true 3 | trailingComma: all 4 | singleQuote: true 5 | printWidth: 80 6 | -------------------------------------------------------------------------------- /tui/media/temp_converter.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/letientai299/7guis/HEAD/tui/media/temp_converter.mp4 -------------------------------------------------------------------------------- /tui/restart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This is useful to restart the applications with some file system watcher tool 3 | # such as nodmeon. 4 | 5 | pkill tui 6 | go build -race -o tui . && ./tui 7 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}), 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | const root = document.getElementById('root') ?? document.body; 7 | 8 | ReactDOM.createRoot(root).render( 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /web/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { describe, expect, it } from 'vitest'; 3 | import App from './App'; 4 | 5 | describe('App', () => { 6 | it('should render without error', async () => { 7 | render(); 8 | expect(screen.getByText(/7GUIs/i)).toBeDefined(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # 7 GUIs 2 | 3 | This repo contains the implementation fo [7GUIs tasks][7guis] for various 4 | platforms. 5 | 6 | [7guis]: https://eugenkiss.github.io/7guis/tasks/ 7 | 8 | ## Web 9 | 10 | The [./web](./web) folder contains implementation using React 18 with its hook. 11 | 12 | ## TUI 13 | 14 | The [./tui](./tui) folder contains TUI implementation in Go. 15 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import react from '@vitejs/plugin-react'; 3 | import eslintPlugin from '@nabla/vite-plugin-eslint'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), eslintPlugin()], 8 | test: { 9 | globals: true, 10 | environment: 'jsdom', 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | __snapshots__/ 27 | .eslintcache 28 | coverage 29 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | ignorePatterns: ['dist'], 8 | parser: '@typescript-eslint/parser', 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:testing-library/react', 12 | 'prettier', 13 | ], 14 | plugins: ['@typescript-eslint', 'testing-library', 'html', 'prettier'], 15 | rules: { 16 | 'prettier/prettier': 'error', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /web/src/components/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | const Counter = () => { 4 | const [count, setCount] = useState(0); 5 | return ( 6 |
7 |
{`Clicked ${count} time(s)`}
8 | 11 |
12 | ); 13 | }; 14 | 15 | export default Counter; 16 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: { 6 | typography: { 7 | DEFAULT: { 8 | css: { 9 | a: { 10 | color: '#3182ce', 11 | '&:hover': { 12 | color: '#2c5282', 13 | }, 14 | }, 15 | }, 16 | }, 17 | }, 18 | }, 19 | }, 20 | plugins: [require('@tailwindcss/typography')], 21 | }; 22 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | select, 7 | input, 8 | button { 9 | background-color: white; 10 | line-height: 1.5em; 11 | border: 1px solid gray; 12 | padding: 0.25rem; 13 | border-radius: 0.25rem; 14 | box-shadow: rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px; 15 | } 16 | 17 | button:disabled, 18 | input:disabled { 19 | background-color: #f0f0f0; 20 | box-shadow: none; 21 | } 22 | } 23 | 24 | @layer utilities { 25 | .debug { 26 | border: 1px solid red; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tui/task/counter.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/rivo/tview" 7 | ) 8 | 9 | func Counter() tview.Primitive { 10 | counter := tview.NewFlex() 11 | 12 | count := 0 13 | txt := tview.NewTextView(). 14 | SetText(fmt.Sprintf("Count is %d", count)) 15 | 16 | label := "Count" 17 | button := tview.NewButton(label).SetSelectedFunc(func() { 18 | count++ 19 | txt.SetText(fmt.Sprintf("Count is %d", count)) 20 | }) 21 | 22 | counter.AddItem(txt, 0, 1, false) 23 | counter.AddItem(button, 0, 1, true) 24 | counter.SetBorder(true) 25 | 26 | return centerScreen(counter, 30, 3) 27 | } 28 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /tui/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/letientai299/7guis/tui 2 | 3 | go 1.18 4 | 5 | require github.com/rivo/tview v0.0.0-20220703182358-a13d901d3386 6 | 7 | require ( 8 | github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 9 | github.com/maja42/goval v1.2.1 10 | ) 11 | 12 | require ( 13 | github.com/gdamore/encoding v1.0.0 // indirect 14 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 15 | github.com/mattn/go-runewidth v0.0.13 // indirect 16 | github.com/rivo/uniseg v0.2.0 // indirect 17 | golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 // indirect 18 | golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d // indirect 19 | golang.org/x/text v0.3.6 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /web/src/components/Counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react'; 2 | import { describe, expect, it } from 'vitest'; 3 | import Counter from './Counter'; 4 | 5 | describe('Counter', () => { 6 | it('click on button should increase the counter', async () => { 7 | render(); 8 | const btn = screen.getByRole('button'); 9 | expect(btn).toBeDefined(); 10 | 11 | const div = screen.getByText(/Click/i); 12 | expect(div).toBeDefined(); 13 | expect(div.textContent).toContain(0); 14 | 15 | const n = 10; 16 | for (let i = 0; i < n; i++) { 17 | fireEvent.click(btn); 18 | } 19 | 20 | expect(div.textContent).toContain(n); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: [push, pull_request] 4 | jobs: 5 | web-lint-test: 6 | runs-on: ubuntu-latest 7 | defaults: 8 | run: 9 | working-directory: ./web 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Use Node.js 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: '18' 17 | 18 | - uses: pnpm/action-setup@v2.0.1 19 | name: Install pnpm 20 | id: pnpm-install 21 | with: 22 | version: 7 23 | run_install: false 24 | 25 | - name: Get pnpm store directory 26 | id: pnpm-cache 27 | run: | 28 | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 29 | 30 | - uses: actions/cache@v3 31 | name: Setup pnpm cache 32 | with: 33 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 34 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 35 | restore-keys: | 36 | ${{ runner.os }}-pnpm-store- 37 | 38 | - name: Install dependencies 39 | run: pnpm install 40 | 41 | - run: pnpm lint 42 | - run: pnpm test 43 | -------------------------------------------------------------------------------- /tui/task/util.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/rivo/tview" 6 | ) 7 | 8 | const ( 9 | ColorInvalid = tcell.ColorRed 10 | ColorDisabled = tcell.ColorDarkGray 11 | ) 12 | 13 | func centerScreen(widget tview.Primitive, width, height int) tview.Primitive { 14 | return tview.NewFlex(). 15 | SetDirection(tview.FlexRow). 16 | AddItem(nil, 0, 1, false). 17 | AddItem( 18 | tview.NewFlex(). 19 | AddItem(nil, 0, 1, false). 20 | AddItem(widget, width, 0, true). 21 | AddItem(nil, 0, 1, false), 22 | height, 0, true). 23 | AddItem(nil, 0, 1, false) 24 | } 25 | 26 | func enable(p tview.Primitive) { 27 | var b *tview.Box 28 | switch x := p.(type) { 29 | case *tview.InputField: 30 | x.SetLabelColor(tview.Styles.SecondaryTextColor) 31 | b = x.Box 32 | case *tview.Button: 33 | x.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) 34 | b = x.Box 35 | } 36 | 37 | if b != nil { 38 | b.SetFocusFunc(nil) 39 | } 40 | } 41 | 42 | func disable(p tview.Primitive) { 43 | var b *tview.Box 44 | switch x := p.(type) { 45 | case *tview.InputField: 46 | x.SetLabelColor(ColorDisabled) 47 | b = x.Box 48 | case *tview.Button: 49 | x.SetBackgroundColor(ColorDisabled) 50 | b = x.Box 51 | } 52 | 53 | if b != nil { 54 | b.SetFocusFunc(func() { 55 | b.Blur() 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /web/src/components/TemperatureConverter.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react'; 2 | import { describe, expect, it } from 'vitest'; 3 | import TemperatureConverter from './TemperatureConverter'; 4 | 5 | describe('TemperatureConverter', () => { 6 | it('convert C to F correctly', async () => { 7 | render(); 8 | const c = screen.getByAltText('Celsius'); 9 | expect(c).toBeDefined(); 10 | const f = screen.getByAltText('Fahrenheit'); 11 | expect(f).toBeDefined(); 12 | 13 | fireEvent.change(c, { target: { value: 100 } }); 14 | expect((f as HTMLInputElement).value).toEqual('212'); 15 | }); 16 | 17 | it('convert F to C correctly', async () => { 18 | render(); 19 | const c = screen.getByAltText('Celsius'); 20 | expect(c).toBeDefined(); 21 | const f = screen.getByAltText('Fahrenheit'); 22 | expect(f).toBeDefined(); 23 | 24 | fireEvent.change(f, { target: { value: 122 } }); 25 | expect((c as HTMLInputElement).value).toEqual('50'); 26 | }); 27 | 28 | it(`change color when input invalid`, async () => { 29 | render(); 30 | const f = screen.getByAltText('Fahrenheit'); 31 | // note: if the value is '122x' instead, the test won't work, 32 | // as the received value is somehow just '122' 33 | fireEvent.change(f, { target: { value: 'x122' } }); 34 | expect(f.className).toContain('bg'); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /web/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /web/src/components/TemperatureConverter.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const TempInput = (p: { 4 | name: string; 5 | v: number; 6 | onChange: (n: number) => void; 7 | }) => { 8 | const [value, setValue] = useState(`${p.v}`); 9 | useEffect(() => { 10 | setValue(`${p.v}`); 11 | }, [p.v]); 12 | 13 | const handleChange = (s: string) => { 14 | setValue(s); 15 | const n = Number.parseFloat(s ? s : '0'); 16 | 17 | if (n == null || Number.isNaN(n)) { 18 | return; 19 | } 20 | 21 | p.onChange(n); 22 | }; 23 | 24 | return ( 25 | 35 | ); 36 | }; 37 | 38 | const TemperatureConverter = () => { 39 | const [c, setC] = useState(0); 40 | const [f, setF] = useState(32); 41 | 42 | const cChange = (c: number) => { 43 | const v = c * (9 / 5) + 32; 44 | setF(Math.round(v * 100) / 100); 45 | }; 46 | 47 | const fChange = (f: number) => { 48 | const v = ((f - 32) * 5) / 9; 49 | setC(Math.round(v * 100) / 100); 50 | }; 51 | 52 | return ( 53 |
54 | 55 |
=
56 | 57 |
58 | ); 59 | }; 60 | 61 | export default TemperatureConverter; 62 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-react-ts-template", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "setup": "cd .. && husky install", 7 | "dev": "vite", 8 | "test": "vitest --run", 9 | "test:watch": "vitest --watch", 10 | "lint": "eslint --fix .", 11 | "fmt": "prettier --write ./", 12 | "build": "tsc && vite build", 13 | "preview": "vite preview" 14 | }, 15 | "dependencies": { 16 | "@tailwindcss/typography": "0.5.2", 17 | "react": "^18.0.0", 18 | "react-dom": "^18.0.0", 19 | "tailwindcss": "3.1.4" 20 | }, 21 | "devDependencies": { 22 | "@nabla/vite-plugin-eslint": "1.4.0", 23 | "@testing-library/react": "13.3.0", 24 | "@types/jsdom": "16.2.14", 25 | "@types/react": "^18.0.0", 26 | "@types/react-dom": "^18.0.0", 27 | "@typescript-eslint/eslint-plugin": "5.30.4", 28 | "@typescript-eslint/parser": "5.30.4", 29 | "@vitejs/plugin-react": "^1.3.0", 30 | "autoprefixer": "10.4.7", 31 | "c8": "7.11.3", 32 | "cssnano": "5.1.12", 33 | "cssnano-preset-advanced": "5.3.8", 34 | "eslint": "8.19.0", 35 | "eslint-config-prettier": "8.5.0", 36 | "eslint-plugin-html": "6.2.0", 37 | "eslint-plugin-prettier": "4.2.1", 38 | "eslint-plugin-testing-library": "5.5.1", 39 | "husky": "8.0.1", 40 | "jsdom": "20.0.0", 41 | "lint-staged": "13.0.3", 42 | "postcss": "8.4.14", 43 | "prettier": "2.7.1", 44 | "typescript": "^4.6.3", 45 | "vite": "^2.9.9", 46 | "vitest": "0.17.0" 47 | }, 48 | "lint-staged": { 49 | "*.{js,jsx,ts,tsx}": [ 50 | "eslint --cache --fix", 51 | "vitest related --run" 52 | ], 53 | "*.{js,jsx,ts,tsx,md,html,css}": "prettier --write" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tui/task/timer.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | ) 10 | 11 | func Timer(app *tview.Application) tview.Primitive { 12 | container := tview.NewFlex().SetDirection(tview.FlexRow) 13 | 14 | total := time.Second * 10 15 | p := NewProgress("Elapsed Time", float32(total)) 16 | p.SetFormatter(func(value, max float32) string { 17 | sec := value / float32(time.Second) 18 | return fmt.Sprintf("%.1fs", sec) 19 | }) 20 | container.AddItem(p, 3, 0, false) 21 | 22 | go func() { 23 | timerUpdate(app, p) 24 | }() 25 | 26 | slider := NewSlider("Duration", 27 | int64(time.Second), 28 | int64(total), 29 | int64(20*time.Second), 30 | ).SetFormatter(func(v int64) string { 31 | return time.Duration(v).String() 32 | }).SetChangedFunc(func(v int64) { 33 | p.SetMax(float32(v)) 34 | }) 35 | 36 | container.AddItem(slider, 2, 0, true) 37 | container.AddItem( 38 | tview.NewTextView().SetText("Adjust duration with Left/Right or Mouse click"), 39 | 0, 2, false, 40 | ) 41 | 42 | btn := tview.NewButton("Press Enter to reset timer"). 43 | SetSelectedFunc(func() { 44 | p.SetValue(0) 45 | }) 46 | 47 | container.AddItem(btn, 1, 0, true) 48 | container.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 49 | if event.Key() == tcell.KeyEnter { 50 | p.SetValue(0) 51 | return nil 52 | } 53 | return event 54 | }) 55 | 56 | return container 57 | } 58 | 59 | func timerUpdate(app *tview.Application, p *Progress) { 60 | dur := time.Millisecond * 100 61 | for range time.Tick(dur) { 62 | v := time.Duration(p.GetValue()) 63 | v += dur 64 | max := p.GetMax() 65 | total := time.Duration(max) 66 | if v > total { 67 | v = total 68 | } 69 | 70 | app.QueueUpdateDraw(func() { p.SetValue(float32(v)) }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Cells from './components/Cells'; 3 | import CircleDrawer from './components/CircleDrawer'; 4 | import Counter from './components/Counter'; 5 | import CRUD from './components/CRUD'; 6 | import FlightBooker from './components/FlightBooker'; 7 | import TemperatureConverter from './components/TemperatureConverter'; 8 | import Timer from './components/Timer'; 9 | 10 | function App() { 11 | const guis: { 12 | name: string; 13 | com: React.FC>; 14 | remark?: string; 15 | }[] = [ 16 | { name: 'Counter', com: Counter }, 17 | { name: 'Temperature Converter', com: TemperatureConverter }, 18 | { name: 'Flight Booker', com: FlightBooker }, 19 | { name: 'Timer', com: Timer }, 20 | { name: 'CRUD', com: CRUD }, 21 | { 22 | name: 'Circle Drawer', 23 | com: CircleDrawer, 24 | remark: "Still can't maintain the radius slider while undo/redo", 25 | }, 26 | { 27 | name: 'Cells', 28 | com: Cells, 29 | remark: ` 30 | Not perfect, no shortcut keys, rerender irrelevant cells, doesn't 31 | support functions, ranges, ... 32 | `, 33 | }, 34 | ]; 35 | 36 | return ( 37 |
38 |

39 | 40 | 7GUIs 41 | 42 |

43 | {guis.map((g) => { 44 | return ( 45 |
49 |
50 | {g.name} 51 | {g.remark ?
{g.remark}
: <>} 52 |
53 |
54 | 55 |
56 |
57 | ); 58 | })} 59 |
60 | ); 61 | } 62 | 63 | export default App; 64 | -------------------------------------------------------------------------------- /tui/task/temperature_converter.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/rivo/tview" 8 | ) 9 | 10 | func TemperatureConverter() tview.Primitive { 11 | container := tview.NewFlex().SetDirection(tview.FlexRow) 12 | errDisplay := tview.NewTextView().SetText("OK") 13 | errDisplay.SetBorderPadding(0, 0, 1, 1) 14 | errDisplay.SetDynamicColors(true) 15 | 16 | form := tview.NewForm() 17 | container.AddItem(form, 5, 1, true) 18 | container.AddItem(errDisplay, 2, 1, false) 19 | 20 | celsiusInput := tview.NewInputField(). 21 | SetLabel("Celsius"). 22 | SetText("0") 23 | 24 | form.AddFormItem(celsiusInput) 25 | 26 | fahrenheitInput := tview.NewInputField(). 27 | SetLabel("Fahrenheit"). 28 | SetText("32") 29 | 30 | form.AddFormItem(fahrenheitInput) 31 | form.SetItemPadding(1) 32 | form.SetBorderPadding(0, 0, 0, 0) 33 | form.SetBorder(true) 34 | 35 | setErr := func(s string) { 36 | if len(s) == 0 { 37 | s = "OK" 38 | } 39 | errDisplay.SetText(s) 40 | } 41 | 42 | var c2f, f2c func(string) 43 | 44 | c2f = func(s string) { 45 | if len(s) == 0 { 46 | setErr("") 47 | return 48 | } 49 | 50 | c, err := strconv.ParseFloat(s, 64) 51 | if err != nil { 52 | setErr("[red]Invalid C value: " + s) 53 | return 54 | } 55 | 56 | f := c*9.0/5.0 + 32 57 | 58 | fahrenheitInput.SetChangedFunc(nil) 59 | fahrenheitInput.SetText(fmt.Sprintf("%.2f", f)) 60 | fahrenheitInput.SetChangedFunc(f2c) 61 | } 62 | celsiusInput.SetChangedFunc(c2f) 63 | 64 | f2c = func(s string) { 65 | if len(s) == 0 { 66 | setErr("") 67 | return 68 | } 69 | 70 | f, err := strconv.ParseFloat(s, 64) 71 | if err != nil { 72 | setErr("[red]Invalid F value: " + s) 73 | return 74 | } 75 | c := (f - 32) * 5.0 / 9.0 76 | celsiusInput.SetChangedFunc(nil) 77 | celsiusInput.SetText(fmt.Sprintf("%.2f", c)) 78 | celsiusInput.SetChangedFunc(c2f) 79 | } 80 | fahrenheitInput.SetChangedFunc(f2c) 81 | 82 | container.SetBorder(true) 83 | return centerScreen(container, 40, 9) 84 | } 85 | -------------------------------------------------------------------------------- /tui/task/cell_funcs.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | 7 | "github.com/maja42/goval" 8 | ) 9 | 10 | var cellFuncs = map[string]goval.ExpressionFunction{ 11 | "SUM": fnSUM, 12 | "sum": fnSUM, 13 | 14 | "AVG": fnAVG, 15 | "avg": fnAVG, 16 | 17 | "MAX": fnMAX, 18 | "max": fnMAX, 19 | 20 | "MIN": fnMAX, 21 | "min": fnMIN, 22 | } 23 | 24 | func fnMIN(args ...interface{}) (interface{}, error) { 25 | s := math.MaxFloat64 26 | for _, a := range args { 27 | switch x := a.(type) { 28 | case float64: 29 | if s > x { 30 | s = x 31 | } 32 | case int: 33 | if s > float64(x) { 34 | s = float64(x) 35 | } 36 | default: 37 | return 0, fmt.Errorf("%v is NaN", a) 38 | } 39 | } 40 | return s, nil 41 | } 42 | 43 | func fnMAX(args ...interface{}) (interface{}, error) { 44 | s := -math.MaxFloat64 45 | 46 | for _, a := range args { 47 | switch x := a.(type) { 48 | case float64: 49 | if s < x { 50 | s = x 51 | } 52 | case int: 53 | if s < float64(x) { 54 | s = float64(x) 55 | } 56 | default: 57 | return 0, fmt.Errorf("%v is NaN", a) 58 | } 59 | } 60 | return s, nil 61 | } 62 | 63 | func fnAVG(args ...interface{}) (interface{}, error) { 64 | if len(args) == 0 { 65 | return 0, nil 66 | } 67 | 68 | s, err := fnSUM(args...) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | return s.(float64) / float64(len(args)), nil 74 | } 75 | 76 | func fnSUM(args ...interface{}) (interface{}, error) { 77 | s := float64(0) 78 | for _, a := range args { 79 | switch x := a.(type) { 80 | case float64: 81 | s += x 82 | case int: 83 | s += float64(x) 84 | default: 85 | return float64(0), fmt.Errorf("%v is NaN", a) 86 | } 87 | } 88 | return s, nil 89 | } 90 | 91 | func max(a, b int) int { 92 | if a > b { 93 | return a 94 | } 95 | return b 96 | } 97 | 98 | func min(a, b int) int { 99 | if a < b { 100 | return a 101 | } 102 | return b 103 | } 104 | -------------------------------------------------------------------------------- /tui/task/progress.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | ) 12 | 13 | func NewProgress(label string, max float32) *Progress { 14 | return &Progress{ 15 | Mutex: &sync.Mutex{}, 16 | Box: tview.NewBox(), 17 | max: max, 18 | label: label, 19 | formatValue: func(value, max float32) string { 20 | percent := value * 100 / max 21 | return fmt.Sprintf("%0.2f%%", percent) 22 | }, 23 | } 24 | } 25 | 26 | type Progress struct { 27 | *tview.Box 28 | *sync.Mutex 29 | 30 | value float32 31 | max float32 32 | label string 33 | formatValue func(value, max float32) string 34 | } 35 | 36 | func (p *Progress) SetMax(max float32) *Progress { 37 | p.Lock() 38 | defer p.Unlock() 39 | p.max = max 40 | return p 41 | } 42 | 43 | func (p *Progress) SetFormatter(fn func(value, max float32) string) *Progress { 44 | p.Lock() 45 | defer p.Unlock() 46 | p.formatValue = fn 47 | return p 48 | } 49 | 50 | func (p *Progress) SetValue(value float32) *Progress { 51 | p.Lock() 52 | defer p.Unlock() 53 | p.value = value 54 | return p 55 | } 56 | 57 | func (p *Progress) Draw(screen tcell.Screen) { 58 | p.Box.DrawForSubclass(screen, p) 59 | x, y, width, _ := p.GetInnerRect() 60 | labelLine := fmt.Sprintf(`%s (%s)`, p.label, p.formatValue(p.value, p.max)) 61 | tview.Print( 62 | screen, labelLine, x, y, width, tview.AlignLeft, 63 | tview.Styles.PrimaryTextColor, 64 | ) 65 | 66 | done := int(math.Floor(float64(p.value * float32(width) / p.max))) 67 | if done > width { 68 | done = width 69 | } 70 | 71 | progressLine := strings.Repeat("▆", done) + strings.Repeat("▁", width-done) 72 | tview.Print( 73 | screen, progressLine, x, y+1, width, tview.AlignLeft, 74 | tview.Styles.PrimaryTextColor, 75 | ) 76 | } 77 | 78 | func (p *Progress) GetMax() float32 { 79 | p.Lock() 80 | defer p.Unlock() 81 | return p.max 82 | } 83 | 84 | func (p *Progress) GetValue() float32 { 85 | p.Lock() 86 | defer p.Unlock() 87 | return p.value 88 | } 89 | -------------------------------------------------------------------------------- /tui/task/circle_drawer.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "github.com/rivo/tview" 5 | ) 6 | 7 | func CircleDrawer() tview.Primitive { 8 | cd := &circleDrawer{} 9 | return cd.render() 10 | } 11 | 12 | type action struct { 13 | up func() 14 | down func() 15 | } 16 | 17 | type circleDrawer struct { 18 | circles []*circle 19 | actions []action 20 | actionIndex int 21 | canvas *CircleCanvas 22 | } 23 | 24 | func (cd *circleDrawer) render() tview.Primitive { 25 | container := tview.NewFlex().SetDirection(tview.FlexRow) 26 | controls := tview.NewFlex(). 27 | AddItem(tview.NewButton("Undo").SetSelectedFunc(cd.undo), 0, 1, false). 28 | AddItem(tview.NewBox(), 1, 0, false). 29 | AddItem(tview.NewButton("Redo").SetSelectedFunc(cd.redo), 0, 1, false) 30 | 31 | container.AddItem(controls, 3, 0, true) 32 | cd.canvas = NewCircleCanvas(). 33 | SetOnUpdateCircle(cd.onUpdate). 34 | SetOnAddCircle(cd.onAdd) 35 | 36 | container.AddItem(cd.canvas, 0, 1, true) 37 | return container 38 | } 39 | 40 | func (cd *circleDrawer) undo() { 41 | if cd.actionIndex == 0 { 42 | return // can't undo 43 | } 44 | 45 | cd.actionIndex-- 46 | a := cd.actions[cd.actionIndex] 47 | a.down() 48 | cd.canvas.setCircles(cd.circles) 49 | } 50 | 51 | func (cd *circleDrawer) redo() { 52 | if cd.actionIndex == len(cd.actions) { 53 | return // can't redo 54 | } 55 | 56 | a := cd.actions[cd.actionIndex] 57 | cd.actionIndex++ 58 | a.up() 59 | cd.canvas.setCircles(cd.circles) 60 | } 61 | 62 | func (cd *circleDrawer) onUpdate(c *circle, oldR int, newR int) { 63 | a := action{ 64 | up: func() { c.r = newR }, 65 | down: func() { c.r = oldR }, 66 | } 67 | a.up() 68 | cd.addAction(a) 69 | } 70 | 71 | func (cd *circleDrawer) onAdd(c *circle) { 72 | a := action{ 73 | up: func() { cd.circles = append(cd.circles, c) }, 74 | down: func() { cd.circles = cd.circles[:len(cd.circles)-1] }, 75 | } 76 | a.up() 77 | cd.addAction(a) 78 | } 79 | 80 | func (cd *circleDrawer) addAction(a action) { 81 | if cd.actionIndex < len(cd.actions) { 82 | cd.actions[cd.actionIndex] = a 83 | cd.actionIndex++ 84 | return 85 | } 86 | 87 | cd.actions = append(cd.actions, a) 88 | cd.actionIndex++ 89 | } 90 | -------------------------------------------------------------------------------- /web/src/components/Timer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | const min = 1000; 4 | const max = 20 * 1000; 5 | 6 | const Timer = () => { 7 | const [ms, setMs] = useState(5000); 8 | 9 | // for the Reset to button to work, we use a dummy value that will always 10 | // change when Reset is clicked, to trigger effect execution 11 | const [trigger, setTrigger] = useState(0); 12 | const elapseRef = useRef(null); 13 | const gaugeRef = useRef(null); 14 | 15 | useEffect(() => { 16 | let id: number; 17 | let remain = 0; 18 | if (elapseRef && elapseRef.current) { 19 | const s = elapseRef.current.innerText?.replace('s', ''); 20 | if (s) { 21 | remain = parseFloat(s) * 1000; 22 | } 23 | } 24 | 25 | let last = new Date(); 26 | 27 | const draw = () => { 28 | id = requestAnimationFrame(draw); 29 | const now = new Date(); 30 | remain += now.getTime() - last.getTime(); 31 | last = now; 32 | 33 | if (remain > ms) { 34 | remain = ms; 35 | cancelAnimationFrame(id); 36 | } 37 | 38 | if (elapseRef && elapseRef.current) { 39 | elapseRef.current.innerText = `${(remain / 1000).toFixed(2)}s`; 40 | } 41 | 42 | if (gaugeRef && gaugeRef.current) { 43 | const done = (remain * 100) / ms; 44 | const w = `${done}%`; 45 | gaugeRef.current.style.width = w; 46 | } 47 | }; 48 | 49 | draw(); 50 | 51 | return () => { 52 | cancelAnimationFrame(id); 53 | }; 54 | }, [ms, trigger]); 55 | 56 | const reset = () => { 57 | if (elapseRef && elapseRef.current) { 58 | elapseRef.current.innerText = `0.00s`; 59 | } 60 | setTrigger((t) => t + 1); 61 | }; 62 | 63 | return ( 64 |
65 |
Elapsed
66 |
67 |
71 |
72 | 0.00s 73 | 74 |
Duration
75 | setMs(parseInt(e.target.value))} 82 | /> 83 | {(ms / 1000).toFixed(2)}s 84 | 85 | 88 |
89 | ); 90 | }; 91 | export default Timer; 92 | -------------------------------------------------------------------------------- /tui/readme.md: -------------------------------------------------------------------------------- 1 | # TUI implementation in Go of 7GUIs task 2 | 3 | See https://eugenkiss.github.io/7guis/tasks for the descriptions of each task. 4 | 5 | ## About 6 | 7 | - This project is for learning [`tview`](https://github.com/rivo/tview) and 8 | building TUI apps only. **The code is neither optimized nor truely correct**. 9 | 10 | - Keyboard shortcuts navigation is pretty non-standard. Please don't expect `Tab`, 11 | `Shift-Tab` and work as usual. Most of the time, `Tab` and `Shift-Tab` switch 12 | betweent the sidebar and the widget view. Mouse control should works as 13 | expected (albeit rather laggy) for input fields, slider and buttons. 14 | 15 | ## Demo 16 | 17 | ![App](./media/app.png) 18 | 19 | See demos in [`./media`](./media) folder if the content below isn't playable. 20 | 21 | ### Counter 22 | 23 | https://user-images.githubusercontent.com/8386780/178098866-e6c387ea-9be3-452d-b026-ef41519b7722.mp4 24 | 25 | ### Temperature Converter 26 | 27 | https://user-images.githubusercontent.com/8386780/178098884-d4474cc5-f4a3-4544-bb29-abc24520c37f.mp4 28 | 29 | ### Flight Booker 30 | 31 | https://user-images.githubusercontent.com/8386780/178098889-e8eb0be7-beca-45d2-b059-6087ea7e8fbf.mp4 32 | 33 | ### Timer 34 | 35 | https://user-images.githubusercontent.com/8386780/178098891-874489ef-1c9e-42c7-8168-0640233a97c0.mp4 36 | 37 | ### CRUD 38 | 39 | ![CRUD demo](./media/crud.png) 40 | 41 | ### Circle Drawer 42 | 43 | https://user-images.githubusercontent.com/8386780/178098899-25b455e1-552e-40bb-add7-a74b27011c19.mp4 44 | 45 | ### Cells 46 | 47 | https://user-images.githubusercontent.com/8386780/178098904-437d2970-99fe-4424-b80b-57a46f16f45b.mp4 48 | 49 | ## Run 50 | 51 | This project requires go version 1.13 or higher to use go modules. If you clone 52 | the source locally, then just run it via: 53 | 54 | ```sh 55 | $ go run . 56 | ``` 57 | 58 | If you have go1.17 or higher, which support `install` command, you can install 59 | the demo and run via: 60 | 61 | ```sh 62 | $ go install github.com/letientai299/7guis/tui@latest 63 | $ tui 64 | ``` 65 | 66 | The application is not supposed to be useful, so, you might want to remove the 67 | installed binary after play around with it. 68 | 69 | ```sh 70 | $ rm $GOPATH/bin/tui 71 | ``` 72 | 73 | ## Developing tips 74 | 75 | For quick edit and review loop, use `nodemon` to watch for code change, rebuild 76 | and restart the application via: 77 | 78 | ```sh 79 | $ nodemon -w . -e .go -x './restart.sh' 80 | ``` 81 | 82 | ## Lesson learned 83 | 84 | - `tview.Form` overrides each input fields style. Hence, after Temperature 85 | Converter, for other form component, I rather use the field directly to 86 | support color changes during validation. 87 | 88 | - `tview` doesn't have a reactive system, hence, it's very painful to implement 89 | these exercises in it. Or, perhaps I'm just too stupid to figure out the 90 | correct way to implement them. 91 | -------------------------------------------------------------------------------- /tui/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 4 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 5 | github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 h1:QqwPZCwh/k1uYqq6uXSb9TRDhTkfQbO80v8zhnIe5zM= 6 | github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04= 7 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 8 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 9 | github.com/maja42/goval v1.2.1 h1:fyEgzddqPgCZsKcFLk4C6SdCHyEaAHYvtZG4mGzQOHU= 10 | github.com/maja42/goval v1.2.1/go.mod h1:42LU+BQXL/veE9jnTTUOSj38GRmOTSThYSXRVodI5J4= 11 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 12 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/rivo/tview v0.0.0-20220703182358-a13d901d3386 h1:Mi1WlPy6SDxnhljPcE4zFHe1iyWdCxAEkiQfNh18/d0= 16 | github.com/rivo/tview v0.0.0-20220703182358-a13d901d3386/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk= 17 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 18 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 19 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 20 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 21 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 22 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 23 | golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 h1:46ULzRKLh1CwgRq2dC5SlBzEqqNCi8rreOZnNrbqcIY= 24 | golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 25 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 26 | golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= 27 | golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 28 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 29 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 30 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 31 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 33 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 34 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | -------------------------------------------------------------------------------- /web/src/components/FlightBooker.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | enum BookType { 4 | OneWay, 5 | RoundTrip, 6 | } 7 | 8 | const dateFormat = 'dd.mm.yyyy'; 9 | 10 | const isLeapYear = (y: number) => { 11 | return (y % 4 == 0 && y % 100 !== 0) || y % 400 == 0; 12 | }; 13 | 14 | const parseDate = (s: string): { ok: boolean; date?: Date } => { 15 | if (s == '') { 16 | return { ok: true }; 17 | } 18 | 19 | if (s.match(/[^\d\.]/)) { 20 | return { ok: false }; 21 | } 22 | 23 | const ss = s.split('.'); 24 | if ( 25 | ss.length != 3 || 26 | ss[0].length != 2 || 27 | ss[1].length != 2 || 28 | ss[2].length != 4 29 | ) { 30 | return { ok: false }; 31 | } 32 | 33 | const [d, m, y] = ss.map((v) => parseInt(v)); 34 | if (d > 31 || d <= 0) { 35 | return { ok: false }; 36 | } 37 | 38 | let dMax = 28; 39 | if (m == 2 && isLeapYear(y)) { 40 | dMax = 29; 41 | } 42 | if ([1, 3, 5, 7, 8, 10, 12].indexOf(m) >= 0) { 43 | dMax = 31; 44 | } else if (m != 2) { 45 | dMax = 30; 46 | } 47 | 48 | if (d > dMax) { 49 | return { ok: false }; 50 | } 51 | 52 | const r = new Date(y, m - 1, d); 53 | return { ok: true, date: r }; 54 | }; 55 | 56 | const FlightBooker = () => { 57 | const [bookType, setBookType] = useState(BookType.OneWay); 58 | const [departure, setDeparture] = useState(''); 59 | const [arrival, setArrival] = useState(''); 60 | 61 | const bookTypeChange = (i: number) => { 62 | setBookType(i as BookType); 63 | }; 64 | 65 | const labelClass = 'self-center'; 66 | const canBook = () => { 67 | if (bookType == BookType.OneWay) { 68 | return departure != '' && parseDate(departure).ok; 69 | } 70 | 71 | if (departure == '' || arrival == '') { 72 | return false; 73 | } 74 | 75 | const d = parseDate(departure); 76 | const a = parseDate(arrival); 77 | if (!d.ok || !a.ok || !d.date || !a.date) { 78 | return false; 79 | } 80 | 81 | return a.date.getTime() > d.date.getTime(); 82 | }; 83 | 84 | const inform = () => { 85 | alert('Booked!'); 86 | }; 87 | 88 | return ( 89 |
90 |
Book Type
91 | 95 | 96 |
Departure
97 | setDeparture(e.target.value)} 100 | className={parseDate(departure).ok ? '' : 'bg-red-400'} 101 | placeholder={dateFormat} 102 | value={departure} 103 | /> 104 | 105 |
Arrival
106 | 107 | setArrival(e.target.value)} 112 | placeholder={dateFormat} 113 | disabled={bookType == BookType.OneWay} 114 | /> 115 | 116 | 123 |
124 | ); 125 | }; 126 | 127 | export default FlightBooker; 128 | -------------------------------------------------------------------------------- /tui/task/slider.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | ) 12 | 13 | type Slider struct { 14 | *tview.Box 15 | label string 16 | min int64 17 | max int64 18 | val int64 19 | formatValue func(value int64) string 20 | step int64 21 | changed func(int64) 22 | } 23 | 24 | func NewSlider(label string, min, val, max int64) *Slider { 25 | unit := (max - min) / 100 26 | if unit == 0 { 27 | unit = 1 28 | } 29 | 30 | return &Slider{ 31 | Box: tview.NewBox(), 32 | min: min, 33 | val: val, 34 | max: max, 35 | step: unit, 36 | label: label, 37 | formatValue: func(value int64) string { return strconv.FormatInt(value, 10) }, 38 | changed: func(int64) {}, 39 | } 40 | } 41 | 42 | func (p *Slider) SetChangedFunc(fn func(v int64)) *Slider { 43 | p.changed = fn 44 | return p 45 | } 46 | 47 | func (p *Slider) SetValue(v int64) *Slider { 48 | p.val = v 49 | if p.changed != nil { 50 | p.changed(v) 51 | } 52 | 53 | return p 54 | } 55 | 56 | func (p *Slider) SetFormatter(fn func(v int64) string) *Slider { 57 | p.formatValue = fn 58 | return p 59 | } 60 | 61 | func (p *Slider) Draw(screen tcell.Screen) { 62 | var lineChar = "═" 63 | if p.HasFocus() { 64 | lineChar = "▬" 65 | } 66 | 67 | const btn = "█" 68 | p.Box.DrawForSubclass(screen, p) 69 | x, y, width, _ := p.GetInnerRect() 70 | labelLine := fmt.Sprintf(`%s (%s)`, p.label, p.formatValue(p.val)) 71 | tview.Print( 72 | screen, labelLine, x, y, width, tview.AlignLeft, 73 | tview.Styles.PrimaryTextColor, 74 | ) 75 | 76 | left := int( 77 | math.Floor(float64(float32(p.val-p.min)/float32(p.max-p.min)* 78 | float32(width))), 79 | ) + 1 80 | if left > width { 81 | left = width 82 | } 83 | 84 | leftLine := strings.Repeat(lineChar, left-1) + btn 85 | tview.Print( 86 | screen, leftLine, x, y+1, width, tview.AlignLeft, 87 | tview.Styles.TertiaryTextColor, 88 | ) 89 | 90 | rightLine := strings.Repeat(lineChar, width-left) 91 | tview.Print( 92 | screen, rightLine, x, y+1, width, tview.AlignRight, 93 | tview.Styles.PrimaryTextColor, 94 | ) 95 | } 96 | 97 | func (p *Slider) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { 98 | return p.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { 99 | oldVal := p.val 100 | switch event.Key() { 101 | case tcell.KeyLeft: 102 | p.val -= p.step 103 | if p.val < p.min { 104 | p.val = p.min 105 | } 106 | case tcell.KeyRight: 107 | p.val += p.step 108 | if p.val > p.max { 109 | p.val = p.max 110 | } 111 | } 112 | 113 | if oldVal != p.val { 114 | p.changed(p.val) 115 | } 116 | }) 117 | } 118 | 119 | func (p *Slider) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { 120 | return p.WrapMouseHandler( 121 | func( 122 | action tview.MouseAction, 123 | event *tcell.EventMouse, 124 | setFocus func(p tview.Primitive), 125 | ) (consumed bool, capture tview.Primitive) { 126 | x, y := event.Position() 127 | if !p.InRect(x, y) { 128 | return false, nil 129 | } 130 | setFocus(p) 131 | rx, _, w, _ := p.GetInnerRect() 132 | 133 | if (action == tview.MouseMove && event.Buttons() == tcell.ButtonPrimary) || 134 | action == tview.MouseLeftClick { 135 | left := x - rx + 1 136 | f := math.Round(float64(left) / float64(w) * float64(p.max-p.min)) 137 | p.val = int64(f) + p.min 138 | p.changed(p.val) 139 | return true, p 140 | } 141 | 142 | return false, nil 143 | }) 144 | } 145 | -------------------------------------------------------------------------------- /tui/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | 8 | "github.com/gdamore/tcell/v2" 9 | "github.com/rivo/tview" 10 | 11 | "github.com/letientai299/7guis/tui/task" 12 | ) 13 | 14 | const url7GUIs = "https://eugenkiss.github.io/7guis/tasks/" 15 | 16 | func tasks(app *tview.Application) []Task { 17 | tasks := []Task{ 18 | {name: "Counter", widget: task.Counter()}, 19 | {name: "Temperature Converter", widget: task.TemperatureConverter()}, 20 | {name: "Flight Booker", widget: task.FlightBooker(app)}, 21 | {name: "Timer", widget: task.Timer(app)}, 22 | {name: "CRUD", widget: task.CRUD()}, 23 | {name: "Circle Drawer", widget: task.CircleDrawer()}, 24 | {name: "Cells", widget: task.Cells()}, 25 | } 26 | return tasks 27 | } 28 | 29 | func main() { 30 | configStyles() 31 | 32 | app := createApp() 33 | done := make(chan struct{}) 34 | 35 | go func() { 36 | defer func() { 37 | done <- struct{}{} 38 | }() 39 | if err := app.Run(); err != nil { 40 | panic(err) 41 | } 42 | }() 43 | 44 | go func() { 45 | signals := make(chan os.Signal, 1) 46 | signal.Notify( 47 | signals, 48 | syscall.SIGILL, syscall.SIGINT, syscall.SIGTERM, 49 | ) 50 | <-signals 51 | app.Stop() 52 | }() 53 | 54 | <-done 55 | } 56 | 57 | func configStyles() { 58 | tview.Styles = tview.Theme{ 59 | PrimitiveBackgroundColor: tcell.ColorBlack, 60 | ContrastBackgroundColor: tcell.ColorDarkBlue, 61 | MoreContrastBackgroundColor: tcell.ColorGreen, 62 | BorderColor: tcell.ColorWhite, 63 | TitleColor: tcell.ColorWhite, 64 | GraphicsColor: tcell.ColorWhite, 65 | PrimaryTextColor: tcell.ColorGhostWhite, 66 | SecondaryTextColor: tcell.ColorYellow, 67 | TertiaryTextColor: tcell.ColorGreen, 68 | InverseTextColor: tcell.ColorDeepSkyBlue, 69 | ContrastSecondaryTextColor: tcell.ColorDarkCyan, 70 | } 71 | } 72 | 73 | func createApp() *tview.Application { 74 | app := tview.NewApplication() 75 | tasks := tasks(app) 76 | 77 | pages := createPages(tasks) 78 | menu := createSidebar(tasks, pages) 79 | 80 | flex := tview.NewFlex() 81 | flex.AddItem(menu, len(url7GUIs)+3, 1, false) 82 | flex.AddItem(pages, 0, 1, true) 83 | 84 | app.SetRoot(flex, true) 85 | app.EnableMouse(true) 86 | 87 | focusingMenu := false 88 | app.SetFocus(pages) 89 | 90 | app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 91 | key := event.Key() 92 | if key != tcell.KeyTAB && key != tcell.KeyBacktab { 93 | return event 94 | } 95 | 96 | if focusingMenu { 97 | app.SetFocus(pages) 98 | } else { 99 | app.SetFocus(menu) 100 | } 101 | 102 | focusingMenu = !focusingMenu 103 | return nil 104 | }) 105 | 106 | return app 107 | } 108 | 109 | func createPages(tasks []Task) *tview.Pages { 110 | pages := tview.NewPages() 111 | for i, t := range tasks { 112 | var view tview.Primitive 113 | if t.widget != nil { 114 | view = t.widget 115 | } else { 116 | view = tview.NewTextView().SetText(t.name) 117 | } 118 | pages.AddPage(t.name, view, true, i == 0) 119 | } 120 | 121 | pages.SetBorder(true) 122 | return pages 123 | } 124 | 125 | func createSidebar(tasks []Task, pages *tview.Pages) tview.Primitive { 126 | menu := createMenu(tasks, pages) 127 | 128 | frame := tview.NewFrame(menu) 129 | frame.SetBorder(true) 130 | frame.SetBorders(0, 0, 1, 1, 1, 1) 131 | frame.AddText("7GUIs", true, tview.AlignLeft, tcell.ColorWhite) 132 | frame.AddText(url7GUIs, true, tview.AlignLeft, tcell.ColorBlue) 133 | return frame 134 | } 135 | 136 | func createMenu(tasks []Task, pages *tview.Pages) *tview.List { 137 | menu := tview.NewList() 138 | menuWidth := 0 139 | for i, t := range tasks { 140 | if len(t.name) > menuWidth { 141 | menuWidth = len(t.name) 142 | } 143 | menu.AddItem(t.name, "", rune(i+'0'), nil) 144 | } 145 | 146 | menu.ShowSecondaryText(false) 147 | menu.SetChangedFunc(func(_ int, task string, _ string, _ rune) { 148 | pages.SwitchToPage(task) 149 | }) 150 | 151 | menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 152 | if event.Key() != tcell.KeyRune { 153 | return event 154 | } 155 | 156 | if event.Rune() == 'j' { 157 | return tcell.NewEventKey(tcell.KeyDown, event.Rune(), event.Modifiers()) 158 | } 159 | 160 | if event.Rune() == 'k' { 161 | return tcell.NewEventKey(tcell.KeyUp, event.Rune(), event.Modifiers()) 162 | } 163 | 164 | return event 165 | }) 166 | 167 | return menu 168 | } 169 | 170 | type Task struct { 171 | name string 172 | widget tview.Primitive 173 | } 174 | -------------------------------------------------------------------------------- /web/src/components/CRUD.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | type Person = { 4 | first: string; 5 | last: string; 6 | }; 7 | 8 | const initData: Person[] = [...new Array(50)].map((_, i) => { 9 | return { first: `${i} John`, last: `Doe ${i}` }; 10 | }); 11 | 12 | const CRUD = () => { 13 | const [data, setData] = useState(initData); 14 | const [first, setFirst] = useState(''); 15 | const [last, setLast] = useState(''); 16 | const [selected, setSelected] = useState({ first: '', last: '' }); 17 | const [filterPrefix, setFilterPrefix] = useState(''); 18 | const [filtered, setFiltered] = useState(data); 19 | 20 | useEffect(() => { 21 | const r = data.filter( 22 | (d) => 23 | d.first.startsWith(filterPrefix) || d.last.startsWith(filterPrefix), 24 | ); 25 | setFiltered(r); 26 | }, [filterPrefix, data]); 27 | 28 | const select = (p: Person) => { 29 | setSelected(p); 30 | }; 31 | 32 | useEffect(() => { 33 | const i = filtered.indexOf(selected); 34 | if (i < 0) { 35 | return; 36 | } 37 | 38 | const row = document.getElementById('selected-record'); 39 | const div = document.getElementById('data-table'); 40 | if (!row || !div) { 41 | return; 42 | } 43 | 44 | const pos = row.offsetHeight * i; 45 | if (div.scrollTop < pos && div.scrollTop + div.offsetHeight >= pos) { 46 | return; // no need to scroll 47 | } 48 | 49 | div.scrollTop = row.offsetHeight * i; 50 | }, [selected, filtered]); 51 | 52 | const create = () => { 53 | const p: Person = { first: first, last: last }; 54 | setData([...data, p]); 55 | setSelected(p); 56 | }; 57 | 58 | const update = () => { 59 | const i = filtered.indexOf(selected); 60 | if (i >= 0) { 61 | const ui = data.indexOf(selected); 62 | if (ui >= 0) { 63 | const p: Person = { first: first, last: last }; 64 | data[ui] = p; 65 | setData([...data]); 66 | setSelected(p); 67 | } 68 | } 69 | }; 70 | 71 | const remove = () => { 72 | const i = filtered.indexOf(selected); 73 | if (i >= 0) { 74 | const ui = data.indexOf(selected); 75 | if (ui >= 0) { 76 | data.splice(ui, 1); 77 | setSelected({ first: '', last: '' }); 78 | setData([...data]); 79 | } 80 | } 81 | }; 82 | 83 | return ( 84 |
85 |
86 |
Filter prefix
87 | setFilterPrefix(e.target.value)} 92 | /> 93 |
94 | 95 |
96 |
97 | 98 | 99 | {filtered.map((d, i) => { 100 | const highlight = d == selected ? 'text-white bg-cyan-500' : ''; 101 | return ( 102 | select(d)} key={i} id="selected-record"> 103 | 106 | 107 | ); 108 | })} 109 | 110 |
{`${d.first}, ${d.last}`}
111 |
112 | 113 |
114 |
Name
115 | setFirst(e.target.value)} 119 | /> 120 |
Surname
121 | setLast(e.target.value)} 125 | /> 126 |
127 |
128 | 129 |
130 | 137 | 146 | 153 |
154 |
155 | ); 156 | }; 157 | export default CRUD; 158 | -------------------------------------------------------------------------------- /web/src/components/CircleDrawer.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react'; 2 | 3 | type Circle = { 4 | x: number; 5 | y: number; 6 | r: number; 7 | }; 8 | 9 | const defaultRadius = 20; 10 | 11 | const CircleDrawer = () => { 12 | type Action = (data: Circle[]) => void; // mutation on data list 13 | const [actions, setActions] = useState([]); 14 | const [actionNum, setActionNum] = useState(0); 15 | 16 | const data: Circle[] = []; // init empty data, execute action 17 | for (let i = 0; i < actionNum; i++) { 18 | actions[i](data); 19 | } 20 | 21 | const [selected, setSelected] = useState(-1); 22 | const sliderRef = useRef(null); 23 | 24 | const addAction = (a: Action) => { 25 | if (actionNum >= actions.length) { 26 | actions.push(a); 27 | } else { 28 | actions[actionNum] = a; 29 | } 30 | setActionNum(actionNum + 1); 31 | setActions([...actions]); 32 | }; 33 | 34 | const clear = () => { 35 | deactivate(); 36 | addAction((ds) => ds.splice(0, ds.length)); 37 | }; 38 | 39 | const undo = () => { 40 | deactivate(); 41 | if (actionNum > 0) { 42 | setActionNum(actionNum - 1); 43 | } 44 | }; 45 | 46 | const redo = () => { 47 | deactivate(); 48 | if (actionNum < actions.length) { 49 | setActionNum(actionNum + 1); 50 | } 51 | }; 52 | 53 | const addCircle = (e: React.MouseEvent) => { 54 | const svg = e.target as SVGElement; 55 | const rect = svg.getBoundingClientRect(); 56 | 57 | const c: Circle = { 58 | r: defaultRadius, 59 | x: Math.round(e.clientX - rect.x), 60 | y: Math.round(e.clientY - rect.y), 61 | }; 62 | 63 | const existed = 64 | data.filter((o) => { 65 | return o.x == c.x && o.y == c.y && o.r == c.r; 66 | }).length != 0; 67 | 68 | if (!existed) { 69 | addAction((ds) => ds.push(c)); 70 | } 71 | }; 72 | 73 | const draw = (c: Circle, i: number) => { 74 | const fill = i == selected ? 'fill-red-400' : 'fill-transparent'; 75 | return ( 76 | activate(i, e)} 78 | className={`hover:fill-gray-400 ${fill} hover:opacity-90`} 79 | key={JSON.stringify(c)} 80 | cx={c.x} 81 | cy={c.y} 82 | r={c.r} 83 | fill={'none'} 84 | stroke={'gray'} 85 | > 86 | ); 87 | }; 88 | 89 | const deactivate = () => { 90 | if (!sliderRef || !sliderRef.current) { 91 | return; 92 | } 93 | 94 | setSelected(-1); 95 | const slider = sliderRef.current; 96 | slider.style.visibility = 'hidden'; 97 | }; 98 | 99 | const activate = (i: number, e: React.MouseEvent) => { 100 | e.stopPropagation(); 101 | if (!sliderRef || !sliderRef.current) { 102 | return; 103 | } 104 | 105 | const c = data[i]; 106 | const slider = sliderRef.current; 107 | const sliderRect = slider.getBoundingClientRect(); 108 | slider.style.visibility = 'visible'; 109 | slider.style.top = c.y + c.r + sliderRect.height + 'px'; 110 | slider.style.left = c.x - sliderRect.width / 2 + 'px'; 111 | 112 | const input = slider.getElementsByTagName('input')[0]; 113 | input.value = c.r + ''; 114 | 115 | input.oninput = () => { 116 | const circle = e.target as SVGCircleElement; 117 | circle.setAttribute('r', input.value); 118 | }; 119 | 120 | input.onchange = () => { 121 | const r = parseInt(input.value); 122 | addAction((ds) => { 123 | ds[i] = { x: ds[i].x, y: ds[i].y, r: r }; 124 | }); 125 | }; 126 | 127 | setSelected(i); 128 | }; 129 | 130 | return ( 131 |
132 |
133 | 136 | 139 | 142 |
143 | 144 | addCircle(e)} 147 | > 148 | {data.map((c, i) => draw(c, i))} 149 | 150 | 151 |
155 |
Radius
156 | 157 | 158 |
159 |
160 | ); 161 | }; 162 | 163 | export default CircleDrawer; 164 | -------------------------------------------------------------------------------- /tui/task/crud.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/rivo/tview" 7 | ) 8 | 9 | func CRUD() tview.Primitive { 10 | data := []*person{ 11 | {name: "Barry", surname: "Allen"}, 12 | {name: "Bruce", surname: "Wayne"}, 13 | {name: "Clark", surname: "Kent"}, 14 | {name: "Diana", surname: "Prince"}, 15 | } 16 | 17 | c := &crud{ 18 | persons: data, 19 | filtered: append([]*person{}, data...), 20 | } 21 | 22 | return c.render() 23 | } 24 | 25 | type person struct { 26 | name string 27 | surname string 28 | } 29 | 30 | func parsePerson(text string) *person { 31 | ss := strings.Split(text, ",") 32 | p := &person{} 33 | if len(ss) == 0 { 34 | return p 35 | } 36 | 37 | p.surname = strings.TrimSpace(ss[0]) 38 | if len(ss) < 2 { 39 | return p 40 | } 41 | 42 | p.name = strings.TrimSpace(ss[1]) 43 | return p 44 | } 45 | 46 | type persons []*person 47 | 48 | func (p persons) GetCell(row, _ int) *tview.TableCell { 49 | cell := tview.NewTableCell(p[row].String()) 50 | return cell 51 | } 52 | 53 | func (p persons) GetRowCount() int { return len(p) } 54 | func (p persons) GetColumnCount() int { return 1 } 55 | func (p persons) SetCell(row, _ int, cell *tview.TableCell) { p[row] = parsePerson(cell.Text) } 56 | func (p persons) RemoveRow(_ int) {} 57 | func (p persons) RemoveColumn(_ int) {} 58 | func (p persons) InsertRow(_ int) {} 59 | func (p persons) InsertColumn(_ int) {} 60 | func (p persons) Clear() {} 61 | 62 | func (p person) String() string { 63 | if p.surname == "" { 64 | return p.name 65 | } 66 | 67 | if p.name == "" { 68 | return p.surname 69 | } 70 | 71 | return p.surname + ", " + p.name 72 | } 73 | 74 | type crud struct { 75 | persons persons 76 | filtered persons 77 | selected int 78 | table *tview.Table 79 | filterText string 80 | inputName string 81 | inputSurname string 82 | } 83 | 84 | func (c *crud) render() tview.Primitive { 85 | container := tview.NewFlex().SetDirection(tview.FlexRow) 86 | 87 | filterInput := tview.NewInputField(). 88 | SetLabel("Filter prefix"). 89 | SetLabelWidth(16). 90 | SetChangedFunc(c.setFilter) 91 | container.AddItem(filterInput, 1, 0, true) 92 | 93 | c.table = tview.NewTable(). 94 | SetContent(c.filtered). 95 | SetSelectable(true, false). 96 | SetSelectionChangedFunc(func(row, _ int) { 97 | c.selected = row 98 | }) 99 | 100 | c.table.SetBorder(true) 101 | 102 | container.AddItem( 103 | tview.NewFlex(). 104 | AddItem( 105 | c.table, 106 | 0, 1, false, 107 | ). 108 | AddItem( 109 | tview.NewForm(). 110 | AddInputField("Name", "", 20, nil, c.inputNameChange). 111 | AddInputField("Surname", "", 20, nil, c.inputSurnameChange), 112 | 0, 1, true, 113 | ), 114 | 0, 1, true, 115 | ) 116 | 117 | container.AddItem( 118 | tview.NewFlex(). 119 | AddItem(tview.NewButton("Create").SetSelectedFunc(c.onCreate), 0, 1, true). 120 | AddItem(tview.NewBox(), 1, 0, false). 121 | AddItem(tview.NewButton("Update").SetSelectedFunc(c.onUpdate), 0, 1, true). 122 | AddItem(tview.NewBox(), 1, 0, false). 123 | AddItem(tview.NewButton("Delete").SetSelectedFunc(c.onDelete), 0, 1, true), 124 | 1, 0, false, 125 | ) 126 | 127 | return container 128 | } 129 | 130 | func (c *crud) setFilter(text string) { 131 | c.filterText = text 132 | c.onFilter() 133 | } 134 | 135 | func (c *crud) onFilter() { 136 | text := strings.ToLower(c.filterText) 137 | c.filtered = c.filtered[:0] 138 | for i := 0; i < len(c.persons); i++ { 139 | p := c.persons[i] 140 | if strings.HasPrefix(strings.ToLower(p.name), text) || 141 | strings.HasPrefix(strings.ToLower(p.surname), text) { 142 | c.filtered = append(c.filtered, p) 143 | } 144 | } 145 | 146 | c.table.SetContent(c.filtered) 147 | } 148 | 149 | func (c *crud) inputNameChange(text string) { c.inputName = text } 150 | func (c *crud) inputSurnameChange(text string) { c.inputSurname = text } 151 | 152 | func (c *crud) onCreate() { 153 | if c.inputName == "" && c.inputSurname == "" { 154 | return 155 | } 156 | 157 | c.persons = append(c.persons, &person{ 158 | name: c.inputName, 159 | surname: c.inputSurname, 160 | }) 161 | 162 | c.onFilter() 163 | } 164 | 165 | func (c *crud) onUpdate() { 166 | if c.inputName == "" && c.inputSurname == "" { 167 | return 168 | } 169 | 170 | p := c.filtered[c.selected] 171 | p.name = c.inputName 172 | p.surname = c.inputSurname 173 | } 174 | 175 | func (c *crud) onDelete() { 176 | toBeDeleted := c.filtered[c.selected] 177 | n := 0 178 | for _, p := range c.persons { 179 | if p == toBeDeleted { 180 | continue 181 | } 182 | c.persons[n] = p 183 | n++ 184 | } 185 | c.persons = c.persons[:n] 186 | c.onFilter() 187 | } 188 | -------------------------------------------------------------------------------- /web/src/components/Cells.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | class CellData { 4 | text = ''; // the raw expression 5 | value = ''; // computed value 6 | affect: string[] = []; // name of other cells 7 | } 8 | 9 | const Cols = 26; 10 | const Rows = 100; 11 | 12 | const commonStyle = 'border border-gray-500 p-1'; 13 | const thStyle = `font-bold text-right ${commonStyle} bg-zinc-100`; 14 | 15 | const cellName = (r: number, c: number) => { 16 | return `${String.fromCharCode(65 + c)}${r}`; 17 | }; 18 | 19 | const row = ( 20 | r: number, 21 | onChange: (name: string, text: string) => void, 22 | getCellData: (name: string) => CellData, 23 | ) => { 24 | const cells = [ 25 | 26 | {r} 27 | , 28 | ]; 29 | for (let c = 0; c < Cols; c++) { 30 | const name = cellName(r, c); 31 | const data = getCellData(name); 32 | const cell = ( 33 | 34 | onChange(name, s)} 38 | /> 39 | 40 | ); 41 | cells.push(cell); 42 | } 43 | return {cells}; 44 | }; 45 | 46 | const headerRow = () => { 47 | const cells = []; 48 | 49 | let char = 'A'.charCodeAt(0); 50 | for (let i = 0; i < Cols; i++) { 51 | const name = String.fromCharCode(char); 52 | const h = ( 53 | 54 |
{name}
55 | 56 | ); 57 | cells.push(h); 58 | char++; 59 | } 60 | 61 | return cells; 62 | }; 63 | 64 | const Cell = (p: { 65 | text: string; 66 | v: string; 67 | onChange: (s: string) => void; 68 | }) => { 69 | const [editing, setEditing] = useState(false); 70 | const [content, setContent] = useState(p.text); 71 | const [modified, setModified] = useState(false); 72 | 73 | const change = (s: string) => { 74 | setModified(true); 75 | setContent(s); 76 | }; 77 | 78 | const blur = () => { 79 | setEditing(false); 80 | if (modified) { 81 | p.onChange(content); 82 | } 83 | setModified(false); 84 | }; 85 | 86 | return ( 87 | change(e.target.value)} 93 | onDoubleClick={() => setEditing(true)} 94 | readOnly={!editing} 95 | onBlur={blur} 96 | > 97 | ); 98 | }; 99 | 100 | const Cells = () => { 101 | // raw data as a 2d matrix of text content of all cells 102 | const [data, setData] = useState(new Map()); 103 | 104 | const findNeededCells = (text: string) => { 105 | return text.match(/\b[A-Z]\d+\b/g) ?? []; 106 | }; 107 | 108 | const onCellChange = (name: string, text: string) => { 109 | const cur = data.get(name) ?? new CellData(); 110 | cur.text = text; 111 | 112 | // recompute the cell value if needed 113 | if (text.startsWith('=')) { 114 | const needs = findNeededCells(text); 115 | needs?.forEach((s) => { 116 | const needCell = data.get(s) ?? new CellData(); 117 | needCell.affect.push(name); 118 | data.set(s, needCell); 119 | }); 120 | 121 | cur.value = compute(text); 122 | } else { 123 | cur.value = text; 124 | } 125 | data.set(name, cur); 126 | 127 | // trigger recompute all the affected cells 128 | cur.affect.forEach((other) => { 129 | const o = data.get(other) ?? new CellData(); 130 | o.value = compute(o.text); 131 | data.set(other, o); 132 | }); 133 | 134 | setData(new Map(data)); 135 | }; 136 | 137 | const getCellData = (name: string) => { 138 | return data.get(name) ?? new CellData(); 139 | }; 140 | 141 | const compute = (text: string) => { 142 | console.log('compute for ', text); 143 | 144 | let exp = text.slice(1); 145 | const needs = findNeededCells(exp); 146 | 147 | needs.forEach((need) => { 148 | let v = getCellData(need).value; 149 | if (v == '') { 150 | v = '0'; 151 | } 152 | 153 | exp = exp.replaceAll(need, v); 154 | }); 155 | 156 | try { 157 | return eval(exp); 158 | } catch (error) { 159 | return `Error! "${text}"`; 160 | } 161 | }; 162 | 163 | const rows = [...new Array(Rows)].map((_, i) => { 164 | return row(i + 1, onCellChange, getCellData); 165 | }); 166 | 167 | return ( 168 |
169 | 170 | 171 | {headerRow()} 172 | 173 | {rows} 174 |
175 |
176 | ); 177 | }; 178 | 179 | export default Cells; 180 | -------------------------------------------------------------------------------- /tui/task/flight_booker.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gdamore/tcell/v2" 7 | "github.com/rivo/tview" 8 | ) 9 | 10 | type flightBooker struct { 11 | app *tview.Application 12 | pages *tview.Pages 13 | typeInput *tview.DropDown 14 | departureInput *tview.InputField 15 | arrivalInput *tview.InputField 16 | bookButton *tview.Button 17 | focusedIndex int 18 | } 19 | 20 | func FlightBooker(app *tview.Application) tview.Primitive { 21 | fb := &flightBooker{app: app} 22 | return fb.render() 23 | } 24 | 25 | func (fb *flightBooker) render() tview.Primitive { 26 | fb.pages = tview.NewPages() 27 | fb.pages.AddPage("main", fb.renderMain(), true, true) 28 | fb.pages.AddPage("dialog", fb.renderDialog(), true, false) 29 | 30 | return fb.pages 31 | } 32 | 33 | func (fb *flightBooker) renderMain() tview.Primitive { 34 | const labelWidth = 20 35 | const optionWidth = 24 36 | const datePlaceHolder = "dd.mm.yyyy" 37 | 38 | flex := tview.NewFlex().SetDirection(tview.FlexRow) 39 | 40 | fb.typeInput = tview.NewDropDown(). 41 | SetLabel("Flight type"). 42 | SetLabelWidth(labelWidth). 43 | SetFieldWidth(optionWidth). 44 | SetOptions([]string{ 45 | "One way", 46 | "Return", 47 | }, nil). 48 | SetCurrentOption(0) 49 | flex.AddItem(fb.typeInput, 2, 1, true) 50 | 51 | fb.departureInput = tview.NewInputField(). 52 | SetLabel("Departure"). 53 | SetPlaceholder(datePlaceHolder). 54 | SetLabelWidth(labelWidth) 55 | flex.AddItem(fb.departureInput, 2, 1, true) 56 | 57 | fb.arrivalInput = tview.NewInputField(). 58 | SetLabel("Arrival"). 59 | SetPlaceholder(datePlaceHolder). 60 | SetLabelWidth(labelWidth) 61 | flex.AddItem(fb.arrivalInput, 2, 1, false) 62 | 63 | fb.bookButton = tview.NewButton("Book") 64 | flex.AddItem(fb.bookButton, 1, 1, true) 65 | flex.SetInputCapture(fb.inputSwitch) 66 | fb.focusedIndex = 0 // flight type field 67 | 68 | wrap := tview.NewFlex().SetDirection(tview.FlexRow) 69 | wrap.SetBorder(true) 70 | wrap.AddItem(flex, 0, 1, true) 71 | hint := tview.NewTextView().SetText("Use Ctrl-N/Ctrl-P to switch input fields") 72 | wrap.AddItem(hint, 1, 0, false) 73 | 74 | fb.typeInput.SetSelectedFunc(fb.onFlightTypeChange) 75 | fb.typeInput.SetCurrentOption(0) 76 | 77 | fb.departureInput.SetChangedFunc(func(s string) { 78 | _, err := fb.parseDate(s) 79 | if err != nil { 80 | fb.departureInput.SetFieldTextColor(ColorInvalid) 81 | } else { 82 | fb.departureInput.SetFieldTextColor(tview.Styles.PrimaryTextColor) 83 | } 84 | fb.adjustBookButton() 85 | }) 86 | 87 | fb.arrivalInput.SetChangedFunc(func(s string) { 88 | _, err := fb.parseDate(s) 89 | if err != nil { 90 | fb.arrivalInput.SetFieldTextColor(ColorInvalid) 91 | } else { 92 | fb.arrivalInput.SetFieldTextColor(tview.Styles.PrimaryTextColor) 93 | } 94 | fb.adjustBookButton() 95 | }) 96 | 97 | fb.bookButton.SetSelectedFunc(fb.book) 98 | 99 | fb.adjustBookButton() 100 | return centerScreen(wrap, labelWidth+optionWidth, 11) 101 | } 102 | 103 | func (fb *flightBooker) inputSwitch(event *tcell.EventKey) *tcell.EventKey { 104 | switch event.Key() { 105 | case tcell.KeyCtrlN: 106 | fb.focusNext() 107 | case tcell.KeyCtrlP: 108 | fb.focusBack() 109 | default: 110 | return event 111 | } 112 | 113 | return nil 114 | } 115 | 116 | func (fb *flightBooker) focusNext() { 117 | fs := fb.enabledFields() 118 | if fb.focusedIndex < len(fs)-1 { 119 | fb.focusedIndex++ 120 | } else { 121 | fb.focusedIndex = 0 122 | } 123 | fb.app.SetFocus(fs[fb.focusedIndex]) 124 | } 125 | 126 | func (fb *flightBooker) focusBack() { 127 | fs := fb.enabledFields() 128 | if fb.focusedIndex > 0 { 129 | fb.focusedIndex-- 130 | } else { 131 | fb.focusedIndex = len(fs) - 1 132 | } 133 | fb.app.SetFocus(fs[fb.focusedIndex]) 134 | } 135 | 136 | func (fb *flightBooker) enabledFields() []tview.Primitive { 137 | enables := []tview.Primitive{ 138 | fb.typeInput, fb.departureInput, 139 | } 140 | if index, _ := fb.typeInput.GetCurrentOption(); index == 1 { 141 | enables = append(enables, fb.arrivalInput) 142 | } 143 | 144 | if fb.canBook() { 145 | enables = append(enables, fb.bookButton) 146 | } 147 | return enables 148 | } 149 | 150 | func (fb *flightBooker) adjustBookButton() bool { 151 | v := fb.canBook() 152 | if v { 153 | enable(fb.bookButton) 154 | } else { 155 | disable(fb.bookButton) 156 | } 157 | return v 158 | } 159 | 160 | func (fb *flightBooker) canBook() bool { 161 | depart := fb.departureInput.GetText() 162 | if i, _ := fb.typeInput.GetCurrentOption(); i == 0 { // one way 163 | _, err := fb.parseDate(depart) 164 | return err == nil 165 | } 166 | 167 | arrival := fb.arrivalInput.GetText() 168 | d, errD := fb.parseDate(depart) 169 | a, errA := fb.parseDate(arrival) 170 | if errD != nil || errA != nil { 171 | return false 172 | } 173 | 174 | return d.Before(a) 175 | } 176 | 177 | func (fb *flightBooker) onFlightTypeChange(_ string, index int) { 178 | if index == 0 { // one-way 179 | disable(fb.arrivalInput) 180 | return 181 | } 182 | 183 | enable(fb.arrivalInput) 184 | fb.adjustBookButton() 185 | } 186 | 187 | func (fb *flightBooker) parseDate(v string) (time.Time, error) { 188 | return time.Parse("02.01.2006", v) 189 | } 190 | 191 | func (fb *flightBooker) book() { 192 | fb.pages.SwitchToPage("dialog") 193 | } 194 | 195 | func (fb *flightBooker) renderDialog() tview.Primitive { 196 | dialog := tview.NewFlex(). 197 | SetDirection(tview.FlexRow). 198 | AddItem( 199 | tview.NewTextView(). 200 | SetText("Booked a flight for you"). 201 | SetTextAlign(tview.AlignCenter), 202 | 0, 3, false, 203 | ). 204 | AddItem( 205 | tview.NewButton("OK"). 206 | SetSelectedFunc(func() { 207 | fb.pages.SwitchToPage("main") 208 | }), 209 | 1, 0, true, 210 | ) 211 | 212 | dialog.SetBorder(true) 213 | return centerScreen(dialog, 30, 5) 214 | } 215 | -------------------------------------------------------------------------------- /tui/task/circle_canvas.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import "C" 4 | import ( 5 | "fmt" 6 | "math" 7 | 8 | "github.com/gdamore/tcell/v2" 9 | "github.com/rivo/tview" 10 | ) 11 | 12 | const characterBlockRatio = 0.45 // ratio between height and width of a character 13 | const ( 14 | minRadius = 3 15 | maxRadius = 60 16 | ) 17 | 18 | type point [2]int 19 | 20 | type circle struct { 21 | x, y, r int 22 | } 23 | 24 | func (c circle) center() point { return [2]int{c.x, c.y} } 25 | 26 | // pointsOnCircle lists integer points on the circle 27 | func (c *circle) pointsOnCircle() []point { 28 | var res []point 29 | for py := -c.r; py <= c.r; py++ { 30 | px := int(math.Round(math.Sqrt(float64(c.r*c.r - py*py)))) 31 | y := int(math.Round(float64(py) * characterBlockRatio)) 32 | res = append(res, [2]int{c.x + px, c.y + y}) 33 | res = append(res, [2]int{c.x - px, c.y - y}) 34 | } 35 | 36 | return res 37 | } 38 | 39 | func NewCircleCanvas() *CircleCanvas { 40 | return &CircleCanvas{ 41 | Box: tview.NewBox(), 42 | mCircles: make(map[point]*circle), 43 | centerDot: '⬤', 44 | dot: '🞄', 45 | } 46 | } 47 | 48 | // CircleCanvas limitations: 49 | // - There can only be a single circle for any center point, no matter what the 50 | // radius is 51 | type CircleCanvas struct { 52 | *tview.Box 53 | mouseX, mouseY int 54 | circles []*circle 55 | mCircles map[point]*circle 56 | selected point 57 | centerDot rune 58 | dot rune 59 | slider *Slider 60 | onAddCircle func(c *circle) 61 | onUpdateCircle func(c *circle, oldR, newR int) 62 | } 63 | 64 | func (cc *CircleCanvas) Draw(screen tcell.Screen) { 65 | cc.Box.DrawForSubclass(screen, cc) 66 | if cc.HasFocus() { 67 | if cc.mouseX > 0 && cc.mouseY > 0 { 68 | x, y, w, _ := cc.GetInnerRect() 69 | mouseInfo := fmt.Sprintf("(%d,%d)", cc.mouseX, cc.mouseY) 70 | tview.Print( 71 | screen, mouseInfo, x, y, w, tview.AlignLeft, 72 | tview.Styles.PrimaryTextColor, 73 | ) 74 | screen.SetContent(cc.mouseX+x, cc.mouseY+y, cc.centerDot, nil, 75 | tcell.Style{}. 76 | Foreground(tview.Styles.SecondaryTextColor). 77 | Background(tview.Styles.PrimitiveBackgroundColor), 78 | ) 79 | } 80 | } 81 | 82 | var special *circle 83 | for _, circle := range cc.circles { 84 | if circle.center() == cc.selected { 85 | special = circle 86 | } else { 87 | cc.draw(screen, circle) 88 | } 89 | } 90 | 91 | // draw selected as last for full visibility 92 | if special != nil { 93 | cc.draw(screen, special) 94 | } 95 | 96 | if cc.slider != nil { 97 | cc.slider.Draw(screen) 98 | } 99 | } 100 | 101 | func (cc *CircleCanvas) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { 102 | return cc.WrapMouseHandler(func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { 103 | mx, my := event.Position() 104 | if !cc.InRect(mx, my) { 105 | return false, nil 106 | } 107 | 108 | if cc.slider != nil && cc.slider.InRect(mx, my) { 109 | cc.mouseX = -1 110 | cc.mouseY = -1 111 | return cc.slider.MouseHandler()(action, event, setFocus) 112 | } 113 | 114 | rectX, rectY, _, _ := cc.GetInnerRect() 115 | cc.mouseX = mx - rectX 116 | cc.mouseY = my - rectY 117 | if action == tview.MouseLeftClick { 118 | if cc.slider != nil { 119 | cc.removeSelected() 120 | return true, cc 121 | } 122 | 123 | if !cc.HasFocus() { 124 | setFocus(cc) 125 | } else { 126 | cc.addOrSelectCircle(cc.mouseX, cc.mouseY) 127 | } 128 | } 129 | 130 | return true, cc 131 | }) 132 | } 133 | 134 | func (cc *CircleCanvas) addOrSelectCircle(x int, y int) { 135 | c := &circle{x: x, y: y, r: 10} 136 | 137 | center := [2]int{x, y} 138 | if cc.mCircles[center] == nil { 139 | cc.circles = append(cc.circles, c) 140 | cc.mCircles[center] = c 141 | cc.removeSelected() 142 | if cc.onAddCircle != nil { 143 | cc.onAddCircle(c) 144 | } 145 | return 146 | } 147 | 148 | // has a circle at the selected center already 149 | cc.selected = center 150 | c = cc.mCircles[center] 151 | cc.slider = NewSlider("Radius", minRadius, int64(c.r), maxRadius) 152 | rx, ry, _, _ := cc.GetInnerRect() 153 | 154 | sliderW := maxRadius - minRadius 155 | cc.slider.SetRect(rx+center[0]-sliderW/2, ry+center[1]+3, sliderW, 3) 156 | cc.slider.SetChangedFunc(func(v int64) { 157 | c := cc.mCircles[center] 158 | oldR := c.r 159 | c.r = int(v) 160 | if cc.onUpdateCircle != nil { 161 | cc.onUpdateCircle(c, oldR, c.r) 162 | } 163 | }) 164 | } 165 | 166 | func (cc *CircleCanvas) removeSelected() { 167 | cc.selected = [2]int{-1, -1} // clear selected 168 | cc.slider = nil 169 | } 170 | 171 | func (cc *CircleCanvas) draw(screen tcell.Screen, c *circle) { 172 | x, y, w, h := cc.GetInnerRect() 173 | color := tview.Styles.PrimaryTextColor 174 | if c.center() == cc.selected { 175 | color = tview.Styles.TertiaryTextColor 176 | } 177 | 178 | style := tcell.Style{}. 179 | Foreground(color). 180 | Background(tview.Styles.PrimitiveBackgroundColor) 181 | screen.SetContent(c.x+x, c.y+y, cc.centerDot, nil, style) 182 | 183 | points := c.pointsOnCircle() 184 | for _, p := range points { 185 | px, py := p[0], p[1] // relative coordinate within canvas 186 | if px > w-1 || py > h-1 || px < 0 || py < 0 { 187 | continue 188 | } 189 | 190 | screen.SetContent(px+x, py+y, cc.dot, nil, style) 191 | } 192 | } 193 | 194 | func (cc *CircleCanvas) Focus(delegate func(p tview.Primitive)) { 195 | if cc.slider != nil { 196 | delegate(cc.slider) 197 | } else { 198 | delegate(cc.Box) 199 | } 200 | } 201 | 202 | func (cc *CircleCanvas) HasFocus() bool { 203 | if cc.slider != nil { 204 | return cc.slider.HasFocus() 205 | } 206 | 207 | return cc.Box.HasFocus() 208 | } 209 | 210 | func (cc *CircleCanvas) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { 211 | return cc.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { 212 | if cc.slider != nil { 213 | cc.slider.InputHandler()(event, setFocus) 214 | } 215 | }) 216 | } 217 | 218 | func (cc *CircleCanvas) SetOnAddCircle(fn func(c *circle)) *CircleCanvas { 219 | cc.onAddCircle = fn 220 | return cc 221 | } 222 | 223 | func (cc *CircleCanvas) SetOnUpdateCircle(fn func(c *circle, oldR, newR int)) *CircleCanvas { 224 | cc.onUpdateCircle = fn 225 | return cc 226 | } 227 | 228 | func (cc *CircleCanvas) setCircles(circles []*circle) { 229 | cc.circles = circles 230 | cc.mCircles = make(map[point]*circle) 231 | for _, c := range cc.circles { 232 | cc.mCircles[c.center()] = c 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /tui/task/cells.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/gdamore/tcell/v2" 11 | "github.com/maja42/goval" 12 | "github.com/rivo/tview" 13 | ) 14 | 15 | const ( 16 | charDots = '…' 17 | ) 18 | 19 | var ( 20 | regexCell = regexp.MustCompile(`\b[A-Za-z]+\d+\b`) 21 | regexRange = regexp.MustCompile(`\b[A-Za-z]+\d+:[a-zA-Z]+\d+\b`) 22 | ) 23 | 24 | func Cells() tview.Primitive { 25 | sh := NewSheet() 26 | return sh 27 | } 28 | 29 | type cellData struct { 30 | raw string 31 | display string 32 | value float64 33 | affects map[string]struct{} 34 | needs map[string]struct{} 35 | err error 36 | } 37 | 38 | func newCellData() *cellData { 39 | return &cellData{ 40 | affects: make(map[string]struct{}), 41 | needs: make(map[string]struct{}), 42 | } 43 | } 44 | 45 | // Sheet supports up to 26 columns since I'm too lazy to write the code for 46 | // column name. 47 | type Sheet struct { 48 | *tview.Box 49 | hint string 50 | hintView *tview.TextView 51 | cellWidth int // amount of character per cell 52 | 53 | txtStyle tcell.Style 54 | headerStyle tcell.Style 55 | focusStyle tcell.Style 56 | hoverStyle tcell.Style 57 | cmtStyle tcell.Style 58 | errStyle tcell.Style 59 | 60 | offset [2]int // coordinate of the top-left visible cell 61 | focused [2]int 62 | hovered [2]int 63 | 64 | editing bool 65 | input *tview.InputField 66 | mem map[string]*cellData 67 | 68 | funcs map[string]goval.ExpressionFunction 69 | recomputed map[string]struct{} // store cell that's already recomputed, avoid infinite recursion 70 | } 71 | 72 | func NewSheet() *Sheet { 73 | hint := strings.TrimSpace(` 74 | Navigate: ←↑→↓, hklj, Home ^ 75 | Enter to turn on edit mode, then Enter to commit change 76 | `) 77 | 78 | const cellWidth = 15 79 | 80 | st := tview.Styles 81 | s := &Sheet{ 82 | Box: tview.NewBox(), 83 | 84 | input: tview.NewInputField(). 85 | SetLabelWidth(0). 86 | SetFieldWidth(cellWidth), 87 | 88 | mem: make(map[string]*cellData), 89 | 90 | hint: hint, 91 | hintView: tview.NewTextView().SetText(hint), 92 | offset: [2]int{1, 1}, // unlike array, cell start with 1 93 | focused: [2]int{1, 1}, 94 | cellWidth: cellWidth, 95 | 96 | txtStyle: tcell.Style{}.Foreground(st.PrimaryTextColor).Background(st.PrimitiveBackgroundColor), 97 | headerStyle: tcell.Style{}.Foreground(st.TertiaryTextColor).Background(st.PrimitiveBackgroundColor), 98 | focusStyle: tcell.Style{}.Foreground(st.TertiaryTextColor).Background(st.PrimitiveBackgroundColor), 99 | hoverStyle: tcell.Style{}.Foreground(st.SecondaryTextColor).Background(st.PrimitiveBackgroundColor), 100 | cmtStyle: tcell.Style{}.Foreground(st.ContrastSecondaryTextColor).Background(st.PrimitiveBackgroundColor), 101 | errStyle: tcell.Style{}.Foreground(ColorInvalid).Background(st.PrimitiveBackgroundColor), 102 | 103 | funcs: cellFuncs, 104 | recomputed: make(map[string]struct{}), 105 | } 106 | 107 | return s 108 | } 109 | 110 | func (sh *Sheet) Draw(screen tcell.Screen) { 111 | sh.Box.DrawForSubclass(screen, sh) 112 | nRows, nCols, c1w := sh.viewport() 113 | sh.adjustViewport(nRows, nCols) 114 | sh.drawSheet(screen, nRows, nCols, c1w) 115 | sh.drawHovered(screen, nRows, nCols, c1w) 116 | sh.drawFocusedCell(screen, c1w) 117 | sh.drawHint(screen) 118 | } 119 | 120 | func (sh *Sheet) viewport() (nRows int, nCols int, c1w int) { 121 | _, _, w, h := sh.GetInnerRect() 122 | nRows = (h - 123 | 2 /*hint*/ - 124 | 2 /*last and incomplete row*/ - 125 | 1 /*focused cell detail*/) / 2 126 | offset := sh.offset 127 | // 2 vertical line and 1 reserved space, in case, e.g. move from 99 to 100 128 | c1w = len(strconv.Itoa(offset[0]+nRows)) + 3 129 | nCols = (w - c1w) / (sh.cellWidth + 1) 130 | return 131 | } 132 | 133 | func (sh *Sheet) adjustViewport(nRows int, nCols int) { 134 | offset := sh.offset 135 | if sh.focused[0] < offset[0] { 136 | offset[0] = sh.focused[0] 137 | } 138 | 139 | if sh.focused[1] < offset[1] { 140 | offset[1] = sh.focused[1] 141 | } 142 | 143 | if sh.focused[0]-offset[0] > nRows-1 { 144 | offset[0] = sh.focused[0] - nRows + 1 145 | } 146 | 147 | if sh.focused[1]-offset[1] > nCols-1 { 148 | offset[1] = sh.focused[1] - nCols + 1 149 | } 150 | 151 | sh.offset = offset 152 | } 153 | 154 | func (sh *Sheet) drawSheet( 155 | screen tcell.Screen, 156 | nRows int, // num visible rows, including the header row 157 | nCols int, // num visible columns, including the header col 158 | c1w int, // width of header col 159 | ) { 160 | x, y, w, _ := sh.GetInnerRect() 161 | y++ 162 | for r := 0; r < nRows+1; r++ { 163 | if r != 0 { 164 | screen.SetContent(x, 2*r+y, tview.Borders.LeftT, nil, sh.txtStyle) 165 | } else { 166 | screen.SetContent(x, 2*r+y, tview.Borders.TopLeft, nil, sh.txtStyle) 167 | } 168 | 169 | screen.SetContent(x, 2*r+y+1, tview.Borders.Vertical, nil, sh.txtStyle) 170 | if r != 0 { 171 | rowName := strconv.Itoa(r - 1 + sh.offset[0]) 172 | sh.drawTxtAlignRight(screen, rowName, sh.headerStyle, x, 2*r+y+1, c1w) 173 | } 174 | screen.SetContent(x+c1w, 2*r+y+1, tview.Borders.Vertical, nil, sh.txtStyle) 175 | 176 | // line 177 | for i := 1; i < w; i++ { 178 | c := tview.Borders.Horizontal 179 | if (i-c1w)%(sh.cellWidth+1) == 0 { 180 | if r != 0 { 181 | c = tview.Borders.Cross 182 | } else { 183 | c = tview.Borders.TopT 184 | } 185 | } 186 | screen.SetContent(x+i, 2*r+y, c, nil, sh.txtStyle) 187 | } 188 | 189 | // content 190 | for c := 1; c < nCols+1; c++ { 191 | pos := x + c1w + (c-1)*(sh.cellWidth+1) 192 | if r == 0 { 193 | colName := sh.colName(c - 1 + sh.offset[1]) 194 | sh.drawTxtAlignRight(screen, colName, sh.headerStyle, pos+1, 2*r+y+1, sh.cellWidth) 195 | } else { 196 | cell := [2]int{r - 1 + sh.offset[0], c - 1 + sh.offset[1]} 197 | display, displayStyle := sh.getCellDisplay(cell) 198 | if display != "" { 199 | _, err := strconv.ParseFloat(display, 64) 200 | if err == nil { 201 | // draw number align right 202 | sh.drawTxtAlignRight(screen, display, displayStyle, pos+1, 2*r+y+1, sh.cellWidth) 203 | } else { 204 | sh.drawTxt(screen, display, displayStyle, pos+1, 2*r+y+1, sh.cellWidth) 205 | } 206 | } 207 | } 208 | 209 | screen.SetContent(x+c1w+c*(sh.cellWidth+1), 2*r+y+1, tview.Borders.Vertical, nil, sh.txtStyle) 210 | } 211 | 212 | screen.SetContent(x+w-1, 2*r+y+1, charDots, nil, sh.txtStyle) 213 | } 214 | 215 | // last line 216 | screen.SetContent(x, 2*nRows+y+2, tview.Borders.LeftT, nil, sh.txtStyle) 217 | for i := 1; i < w; i++ { 218 | c := tview.Borders.Horizontal 219 | if (i-c1w)%(sh.cellWidth+1) == 0 { 220 | c = tview.Borders.Cross 221 | } 222 | screen.SetContent(x+i, 2*nRows+y+2, c, nil, sh.txtStyle) 223 | } 224 | } 225 | 226 | func (sh *Sheet) drawFocusedCell(screen tcell.Screen, c1w int) { 227 | sh.highlightCell(screen, sh.focused, c1w, sh.focusStyle) 228 | 229 | x, y, _, _ := sh.GetInnerRect() 230 | name := sh.cellName(sh.focused) 231 | if len(name) < c1w { 232 | name = strings.Repeat(" ", c1w-len(name)) + name 233 | } 234 | name += " = " 235 | detail, detailStyle := sh.getCellDisplay(sh.focused) 236 | 237 | for i, c := range name { 238 | if c != ' ' { 239 | screen.SetContent(x+i, y, c, nil, sh.focusStyle) 240 | } 241 | } 242 | 243 | for i, c := range detail { 244 | if c != ' ' { 245 | screen.SetContent(x+i+len(name), y, c, nil, detailStyle) 246 | } 247 | } 248 | 249 | raw := sh.getCell(sh.focused).raw 250 | if strings.HasPrefix(raw, "=") { 251 | raw = "// " + raw[1:] 252 | n := len(name) + len(detail) + 2 253 | 254 | for i, c := range raw { 255 | if c != ' ' { 256 | screen.SetContent(x+i+n, y, c, nil, sh.hoverStyle) 257 | } 258 | } 259 | } 260 | 261 | if !sh.editing { 262 | screen.HideCursor() 263 | return 264 | } 265 | 266 | y++ 267 | 268 | ix := x + (sh.focused[1]-sh.offset[1])*(sh.cellWidth+1) + c1w + 1 269 | iy := y + (sh.focused[0]-sh.offset[0]+1)*2 + 1 270 | sh.input.SetRect(ix, iy, sh.cellWidth, 1) 271 | sh.input.Draw(screen) 272 | } 273 | 274 | func (sh *Sheet) drawHovered(screen tcell.Screen, rows int, cols int, c1w int) { 275 | if !sh.HasFocus() || 276 | sh.hovered[0] < sh.offset[0] || 277 | sh.hovered[1] < sh.offset[1] || 278 | sh.hovered[0] > sh.offset[0]+rows || 279 | sh.hovered[1] > sh.offset[1]+cols { 280 | return 281 | } 282 | 283 | sh.highlightCell(screen, sh.hovered, c1w, sh.hoverStyle) 284 | } 285 | 286 | func (sh *Sheet) highlightCell(screen tcell.Screen, loc [2]int, c1w int, style tcell.Style) { 287 | x, y, _, _ := sh.GetInnerRect() 288 | y++ 289 | 290 | // calculate focused loc top left location 291 | dy := (loc[0]-sh.offset[0])*2 + 2 292 | dx := (loc[1]-sh.offset[1])*(sh.cellWidth+1) + c1w 293 | 294 | // draw the border using focus style 295 | for i := 0; i < sh.cellWidth; i++ { 296 | screen.SetContent(x+dx+1+i, y+dy, tview.Borders.Horizontal, nil, style) 297 | screen.SetContent(x+dx+1+i, y+dy+2, tview.Borders.Horizontal, nil, style) 298 | } 299 | 300 | screen.SetContent(x+dx, y+dy+1, tview.Borders.Vertical, nil, style) 301 | screen.SetContent(x+dx+sh.cellWidth+1, y+dy+1, tview.Borders.Vertical, nil, style) 302 | 303 | screen.SetContent(x+dx, y+dy, tview.Borders.TopLeft, nil, style) 304 | screen.SetContent(x+dx+sh.cellWidth+1, y+dy, tview.Borders.TopRight, nil, style) 305 | screen.SetContent(x+dx+sh.cellWidth+1, y+dy+2, tview.Borders.BottomRight, nil, style) 306 | screen.SetContent(x+dx, y+dy+2, tview.Borders.BottomLeft, nil, style) 307 | } 308 | 309 | func (sh Sheet) drawHint(screen tcell.Screen) { 310 | x, y, w, h := sh.GetInnerRect() 311 | sh.hintView.SetRect(x, y+h-2, w, 2) 312 | sh.hintView.Draw(screen) 313 | } 314 | 315 | func (sh *Sheet) drawTxt( 316 | screen tcell.Screen, 317 | s string, 318 | style tcell.Style, 319 | x int, 320 | y int, 321 | w int, 322 | ) { 323 | rs := []rune(s) 324 | if len(rs) > w { // offset for vertical line 325 | rs = append(rs[:w-1], charDots) 326 | } 327 | for i, c := range rs { 328 | screen.SetContent(x+i, y, c, nil, style) 329 | } 330 | } 331 | 332 | func (sh *Sheet) drawTxtAlignRight( 333 | screen tcell.Screen, 334 | s string, 335 | style tcell.Style, 336 | x int, 337 | y int, 338 | w int, 339 | ) { 340 | rs := []rune(s) 341 | if len(rs) > w { // offset for vertical line 342 | rs = append(rs[:w-1], charDots) 343 | } 344 | 345 | pad := w - len(rs) 346 | for i, c := range rs { 347 | screen.SetContent(x+pad+i, y, c, nil, style) 348 | } 349 | } 350 | 351 | func (sh *Sheet) InputHandler() func(e *tcell.EventKey, focus func(p tview.Primitive)) { 352 | return sh.WrapInputHandler(func(e *tcell.EventKey, setFocus func(p tview.Primitive)) { 353 | if sh.editing { 354 | if e.Key() == tcell.KeyEnter { 355 | raw := sh.input.GetText() 356 | sh.updateCell(sh.focused, raw) 357 | sh.editing = false 358 | setFocus(sh) 359 | return 360 | } 361 | 362 | // let the input field handle it 363 | sh.input.InputHandler()(e, setFocus) 364 | setFocus(sh.input) 365 | return 366 | } 367 | 368 | k := e.Key() 369 | ru := e.Rune() 370 | 371 | rows, cols, _ := sh.viewport() 372 | 373 | switch { 374 | case k == tcell.KeyEnter: 375 | sh.editing = true 376 | sh.input.SetText(sh.getCell(sh.focused).raw) 377 | 378 | case k == tcell.KeyLeft || (k == tcell.KeyRune && ru == 'h'): 379 | if sh.focused[1] > 1 { 380 | sh.focused[1]-- 381 | } 382 | case k == tcell.KeyRight || (k == tcell.KeyRune && ru == 'l'): 383 | sh.focused[1]++ 384 | 385 | case k == tcell.KeyUp || (k == tcell.KeyRune && ru == 'k'): 386 | if sh.focused[0] > 1 { 387 | sh.focused[0]-- 388 | } 389 | 390 | case k == tcell.KeyDown || (k == tcell.KeyRune && ru == 'j'): 391 | sh.focused[0]++ 392 | 393 | // Move to top row 394 | case (k == tcell.KeyHome && e.Modifiers() == tcell.ModShift) || 395 | (k == tcell.KeyRune && ru == 'g'): 396 | sh.focused[0] = 1 397 | 398 | // Move to left most column 399 | case k == tcell.KeyHome || (k == tcell.KeyRune && ru == '^'): 400 | sh.focused[1] = 1 401 | } 402 | 403 | sh.adjustViewport(rows, cols) 404 | setFocus(sh) 405 | }) 406 | } 407 | 408 | func (sh *Sheet) MouseHandler() func(ac tview.MouseAction, e *tcell.EventMouse, focus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { 409 | return sh.WrapMouseHandler(func(ac tview.MouseAction, e *tcell.EventMouse, f func(p tview.Primitive)) (bool, tview.Primitive) { 410 | mx, my := e.Position() 411 | if !sh.InRect(mx, my) { 412 | sh.hovered = [2]int{0, 0} 413 | return false, nil 414 | } 415 | 416 | rectX, rectY, _, _ := sh.GetInnerRect() 417 | rectY++ 418 | 419 | mx -= rectX 420 | my -= rectY 421 | rows, cols, w := sh.viewport() 422 | r := ((my - 2) / 2) + sh.offset[0] 423 | c := ((mx - w) / sh.cellWidth) + sh.offset[1] 424 | 425 | if r == 0 || c == 0 || r > rows-1 || c > cols { 426 | sh.hovered = [2]int{0, 0} 427 | return false, nil 428 | } 429 | 430 | if e.Buttons() == tcell.ButtonPrimary { 431 | // commit the input text 432 | sh.updateCell(sh.focused, sh.input.GetText()) 433 | sh.editing = false 434 | sh.focused = [2]int{r, c} 435 | } else { 436 | sh.hovered = [2]int{r, c} 437 | } 438 | 439 | return true, sh 440 | }) 441 | } 442 | 443 | func (sh *Sheet) colName(n int) string { 444 | // https://leetcode.com/problems/excel-sheet-column-title/description/ 445 | // https://github.com/letientai299/leetcode/blob/master/go/168.excel-sheet-column-title.go 446 | 447 | bf := bytes.Buffer{} 448 | for n != 0 { 449 | x := (n - 1) % 26 450 | bf.WriteByte(byte('A' + x)) 451 | n = (n - x) / 26 452 | } 453 | 454 | a := bf.Bytes() 455 | for i := len(a)/2 - 1; i >= 0; i-- { 456 | opp := len(a) - 1 - i 457 | a[i], a[opp] = a[opp], a[i] 458 | } 459 | return string(a) 460 | } 461 | 462 | func (sh *Sheet) colNum(s string) int { 463 | // https://leetcode.com/problems/excel-sheet-column-number/description/ 464 | n := 0 465 | for _, c := range s { 466 | n *= 26 467 | n += int(c - 'A' + 1) 468 | } 469 | return n 470 | } 471 | 472 | func (sh *Sheet) cellName(cell [2]int) string { 473 | return sh.colName(cell[1]) + strconv.Itoa(cell[0]) 474 | } 475 | 476 | func (sh *Sheet) HasFocus() bool { 477 | if sh.editing { 478 | return sh.input.HasFocus() 479 | } 480 | return sh.Box.HasFocus() 481 | } 482 | 483 | func (sh *Sheet) Focus(delegate func(p tview.Primitive)) { 484 | if sh.editing { 485 | delegate(sh.input) 486 | } else { 487 | delegate(sh.Box) 488 | } 489 | } 490 | 491 | func (sh *Sheet) getCell(cell [2]int) *cellData { 492 | data, ok := sh.mem[sh.cellName(cell)] 493 | if !ok { 494 | return newCellData() 495 | } 496 | return data 497 | } 498 | 499 | func (sh *Sheet) updateCell(cell [2]int, raw string) { 500 | sh.recomputed = make(map[string]struct{}) 501 | 502 | raw = strings.TrimSpace(raw) 503 | current := sh.cellName(cell) 504 | data, ok := sh.mem[current] 505 | if !ok { 506 | data = newCellData() 507 | } 508 | data.raw = raw 509 | sh.mem[current] = data 510 | sh.compute(current) 511 | } 512 | 513 | func (sh *Sheet) compute(name string) { 514 | if _, ok := sh.recomputed[name]; ok { 515 | return 516 | } 517 | 518 | data, ok := sh.mem[name] 519 | if !ok { 520 | data = newCellData() 521 | } 522 | 523 | defer func() { 524 | sh.mem[name] = data 525 | sh.recomputed[name] = struct{}{} 526 | 527 | for other := range data.needs { 528 | o, ok := sh.mem[other] 529 | if !ok { 530 | o = newCellData() 531 | sh.mem[other] = o 532 | } 533 | o.affects[name] = struct{}{} 534 | } 535 | 536 | for other := range data.affects { 537 | sh.compute(other) 538 | } 539 | }() 540 | 541 | if !strings.HasPrefix(data.raw, "=") { 542 | data.display = data.raw 543 | data.value = 0 544 | data.err = nil 545 | return 546 | } 547 | 548 | needs, exp, err := sh.processRaw(data.raw) 549 | if err != nil { 550 | data.err = err 551 | return 552 | } 553 | 554 | result, err := goval.NewEvaluator().Evaluate(exp, nil, sh.funcs) 555 | if err != nil { 556 | data.err = fmt.Errorf("invalid expression '%s', %v", exp, err) 557 | return 558 | } 559 | 560 | switch v := result.(type) { 561 | case float64: 562 | data.value = v 563 | data.display = fmt.Sprintf("%g", v) 564 | data.err = nil 565 | case int: 566 | data.value = float64(v) 567 | data.display = strconv.Itoa(v) 568 | data.err = nil 569 | default: 570 | data.err = fmt.Errorf("%v is NaN", result) 571 | } 572 | 573 | for other := range data.needs { 574 | if _, stillAffect := needs[other]; !stillAffect { 575 | o, ok := sh.mem[other] 576 | if !ok { 577 | delete(o.affects, name) 578 | } 579 | } 580 | } 581 | 582 | data.needs = needs 583 | } 584 | 585 | func (sh *Sheet) processRaw(raw string) (needs map[string]struct{}, exp string, err error) { 586 | needs = make(map[string]struct{}) 587 | 588 | ranges := regexRange.FindAllString(raw, -1) 589 | var replacements []string 590 | 591 | seenRanges := make(map[string]string) 592 | for _, ran := range ranges { 593 | if _, ok := seenRanges[ran]; ok { 594 | continue 595 | } 596 | 597 | from, to, found := strings.Cut(ran, ":") 598 | if !found { 599 | continue 600 | } 601 | 602 | from = strings.ToUpper(from) 603 | to = strings.ToUpper(to) 604 | 605 | fromLoc := sh.cellNameToLoc(from) 606 | toLoc := sh.cellNameToLoc(to) 607 | var sb strings.Builder 608 | 609 | r1, r2 := min(fromLoc[0], toLoc[0]), max(fromLoc[0], toLoc[0]) 610 | c1, c2 := min(fromLoc[1], toLoc[1]), max(fromLoc[1], toLoc[1]) 611 | 612 | for i := r1; i <= r2; i++ { 613 | for j := c1; j <= c2; j++ { 614 | other := sh.cellName([2]int{i, j}) 615 | if _, ok := needs[other]; !ok { 616 | needs[other] = struct{}{} 617 | } 618 | 619 | f, otherErr := sh.getCellValue(other) 620 | if err == nil { 621 | // don't stop on first error, since we want to collect all the needed cells 622 | err = otherErr 623 | } 624 | 625 | _, _ = fmt.Fprintf(&sb, "%g,", f) 626 | } 627 | } 628 | 629 | if err != nil { 630 | return 631 | } 632 | 633 | rangeValue := sb.String() 634 | rangeValue = rangeValue[:len(rangeValue)-1] 635 | seenRanges[ran] = rangeValue 636 | replacements = append(replacements, ran, rangeValue) 637 | } 638 | 639 | rawNeeds := regexCell.FindAllString(raw, -1) 640 | replaced := make(map[string]struct{}) 641 | for _, rawOther := range rawNeeds { 642 | other := strings.ToUpper(rawOther) 643 | 644 | if _, ok := needs[other]; !ok { 645 | needs[other] = struct{}{} 646 | } 647 | 648 | if _, ok := replaced[rawOther]; !ok { 649 | var f float64 650 | f, err = sh.getCellValue(other) 651 | if err != nil { 652 | break 653 | } 654 | v := strconv.FormatFloat(f, 'g', -1, 64) 655 | replacements = append(replacements, rawOther, v, other, v) 656 | replaced[other] = struct{}{} 657 | replaced[rawOther] = struct{}{} 658 | } 659 | } 660 | 661 | exp = strings.NewReplacer(replacements...).Replace(raw)[1:] 662 | return 663 | } 664 | 665 | func (sh *Sheet) cellNameToLoc(name string) [2]int { 666 | var loc [2]int 667 | i := 0 668 | 669 | for i < len(name) && name[i] < '0' || name[i] > '9' { 670 | i++ 671 | } 672 | 673 | loc[0], _ = strconv.Atoi(name[i:]) 674 | loc[1] = sh.colNum(name[:i]) 675 | return loc 676 | } 677 | 678 | func (sh *Sheet) getCellValue(need string) (float64, error) { 679 | cell, ok := sh.mem[need] 680 | if !ok { 681 | cell = newCellData() 682 | } 683 | 684 | if cell.value != 0 { 685 | return cell.value, nil 686 | } 687 | 688 | if cell.display == "" { 689 | return 0, nil 690 | } 691 | 692 | f, err := strconv.ParseFloat(cell.display, 64) 693 | if err != nil { 694 | return 0, fmt.Errorf("%s(%s) is NaN", need, cell.display) 695 | } 696 | return f, nil 697 | } 698 | 699 | func (sh *Sheet) getCellDisplay(cell [2]int) (string, tcell.Style) { 700 | c := sh.getCell(cell) 701 | if c.err != nil { 702 | return c.err.Error(), sh.errStyle 703 | } 704 | 705 | return c.display, sh.txtStyle 706 | } 707 | --------------------------------------------------------------------------------