├── 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 |
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 |
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 |
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 |
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 | 
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 | 
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 | | {`${d.first}, ${d.last}`} |
106 |
107 | );
108 | })}
109 |
110 |
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 |
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 |
--------------------------------------------------------------------------------