├── .gitignore
├── Gopkg.lock
├── Gopkg.toml
├── Makefile
├── README.md
├── components
└── todo.go
├── index.html
├── index.js
├── index.wasm
├── main.go
├── store
├── helpers.go
├── mutations.go
├── state.go
└── todo.go
└── style
├── style.go
└── todo.go
/.gitignore:
--------------------------------------------------------------------------------
1 | vendor
2 | .idea
--------------------------------------------------------------------------------
/Gopkg.lock:
--------------------------------------------------------------------------------
1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
2 |
3 |
4 | [[projects]]
5 | branch = "master"
6 | digest = "1:ebb1430594f103e9ab0171c02f4e9e29ef63dcc731081400e3194b2dfe921b0e"
7 | name = "github.com/syumai/go-hyperscript"
8 | packages = [
9 | "dom",
10 | "hyperscript",
11 | ]
12 | pruneopts = "UT"
13 | revision = "d8fd65fa414d44b807cc85ce651269dfecaa1a6a"
14 |
15 | [solve-meta]
16 | analyzer-name = "dep"
17 | analyzer-version = 1
18 | input-imports = [
19 | "github.com/syumai/go-hyperscript/dom",
20 | "github.com/syumai/go-hyperscript/hyperscript",
21 | ]
22 | solver-name = "gps-cdcl"
23 | solver-version = 1
24 |
--------------------------------------------------------------------------------
/Gopkg.toml:
--------------------------------------------------------------------------------
1 | # Gopkg.toml example
2 | #
3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html
4 | # for detailed Gopkg.toml documentation.
5 | #
6 | # required = ["github.com/user/thing/cmd/thing"]
7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
8 | #
9 | # [[constraint]]
10 | # name = "github.com/user/project"
11 | # version = "1.0.0"
12 | #
13 | # [[constraint]]
14 | # name = "github.com/user/project2"
15 | # branch = "dev"
16 | # source = "github.com/myfork/project2"
17 | #
18 | # [[override]]
19 | # name = "github.com/x/y"
20 | # version = "2.4.0"
21 | #
22 | # [prune]
23 | # non-go = false
24 | # go-tests = true
25 | # unused-packages = true
26 |
27 |
28 | [prune]
29 | go-tests = true
30 | unused-packages = true
31 |
32 | [[constraint]]
33 | branch = "master"
34 | name = "github.com/syumai/go-hyperscript"
35 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build
2 | build:
3 | GOOS=js GOARCH=wasm go build -o ./index.wasm .
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # go-wasm-todo-list
2 |
3 | * Pure Go ToDo List app running on the browser using the power of WebAssembly.
4 | * This app is implemented in [go-hyperscript](https://github.com/syumai/go-hyperscript/).
5 |
6 | ## Run App
7 |
8 | * https://syumai.github.io/go-wasm-todo-list/
9 |
10 | ## Environment
11 |
12 | * Go 1.12
13 |
14 | ## Development
15 |
16 | ```console
17 | make build
18 | ```
19 |
20 | ## Author
21 |
22 | syumai
23 |
24 | ## License
25 |
26 | MIT
27 |
--------------------------------------------------------------------------------
/components/todo.go:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import (
4 | "strconv"
5 |
6 | h "github.com/syumai/go-hyperscript/hyperscript"
7 | "github.com/syumai/go-wasm-todo-list/store"
8 | "github.com/syumai/go-wasm-todo-list/style"
9 | )
10 |
11 | func toDoItem(props h.Object) h.VNode {
12 | updateToDo := props.Get("updateToDo").(func(int, bool))
13 | toDo := props.Get("toDo").(*store.ToDo)
14 | return h.H("li", nil,
15 | h.H("input", h.Object{
16 | "type": "checkbox",
17 | "checked": toDo.Done,
18 | "onchange": h.FuncOf(func(this h.Value, args []h.Value) interface{} {
19 | updateToDo(toDo.ID, !toDo.Done)
20 | return nil
21 | }),
22 | }),
23 | h.Text(toDo.Title),
24 | )
25 | }
26 |
27 | func toDoList(props h.Object) h.VNode {
28 | toDos := props.Get("toDos").([]*store.ToDo)
29 | elements := make(h.VNodes, len(toDos))
30 | for i, toDo := range toDos {
31 | elements[i] = toDoItem(h.Object{
32 | "toDo": toDo,
33 | "updateToDo": props.Get("updateToDo"),
34 | })
35 | }
36 | return h.H("ul", nil, elements...)
37 | }
38 |
39 | func ToDo(props h.Object) h.VNode {
40 | toDos := props.Get("toDos").([]*store.ToDo)
41 | doingToDos, doneToDos := store.SeparateToDos(toDos)
42 | return h.H("div", nil,
43 | h.H("div", h.Object{"className": "input"},
44 | h.H("form", h.Object{
45 | "autocomplete": "off",
46 | "onsubmit": props.Func("appendToDo"),
47 | },
48 | h.H("input", h.Object{
49 | "type": "text",
50 | "name": "title",
51 | "placeholder": "Input title",
52 | "value": props.String("title"),
53 | "oninput": props.Func("setTitle"),
54 | }),
55 | h.H("button", nil, h.Text("Add")),
56 | ),
57 | ),
58 | h.H("div", h.Object{"style": style.ListContainerStyle},
59 | h.H("div", h.Object{"style": style.ListStyle},
60 | h.H("h3", nil, h.Text("Doing")),
61 | h.H("div", nil, h.Text("Count: "+strconv.Itoa(len(doingToDos)))),
62 | h.H(toDoList, h.Object{
63 | "updateToDo": props.Get("updateToDo"),
64 | "toDos": doingToDos,
65 | }),
66 | ),
67 | h.H("div", h.Object{"style": style.ListStyle},
68 | h.H("h3", nil, h.Text("Done")),
69 | h.H("div", nil, h.Text("Count: "+strconv.Itoa(len(doneToDos)))),
70 | h.H(toDoList, h.Object{
71 | "updateToDo": props.Get("updateToDo"),
72 | "toDos": doneToDos,
73 | }),
74 | ),
75 | ),
76 | )
77 | }
78 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | go-wasm-todo-list
7 |
8 |
9 |
10 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | // Copyright 2018 The Go Authors. All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 |
5 | (() => {
6 | window.global = window;
7 |
8 | // Map web browser API and Node.js API to a single common API (preferring web standards over Node.js API).
9 | let outputBuf = "";
10 | global.fs = {
11 | constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
12 | writeSync(fd, buf) {
13 | outputBuf += decoder.decode(buf);
14 | const nl = outputBuf.lastIndexOf("\n");
15 | if (nl != -1) {
16 | console.log(outputBuf.substr(0, nl));
17 | outputBuf = outputBuf.substr(nl + 1);
18 | }
19 | return buf.length;
20 | },
21 | write(fd, buf, offset, length, position, callback) {
22 | if (offset !== 0 || length !== buf.length || position !== null) {
23 | throw new Error("not implemented");
24 | }
25 | const n = this.writeSync(fd, buf);
26 | callback(null, n);
27 | },
28 | open(path, flags, mode, callback) {
29 | const err = new Error("not implemented");
30 | err.code = "ENOSYS";
31 | callback(err);
32 | },
33 | read(fd, buffer, offset, length, position, callback) {
34 | const err = new Error("not implemented");
35 | err.code = "ENOSYS";
36 | callback(err);
37 | },
38 | fsync(fd, callback) {
39 | callback(null);
40 | },
41 | };
42 |
43 | const encoder = new TextEncoder("utf-8");
44 | const decoder = new TextDecoder("utf-8");
45 |
46 | global.Go = class {
47 | constructor() {
48 | this.argv = ["js"];
49 | this.env = {};
50 | this.exit = (code) => {
51 | if (code !== 0) {
52 | console.warn("exit code:", code);
53 | }
54 | };
55 | this._exitPromise = new Promise((resolve) => {
56 | this._resolveExitPromise = resolve;
57 | });
58 | this._pendingEvent = null;
59 | this._scheduledTimeouts = new Map();
60 | this._nextCallbackTimeoutID = 1;
61 |
62 | const mem = () => {
63 | // The buffer may change when requesting more memory.
64 | return new DataView(this._inst.exports.mem.buffer);
65 | }
66 |
67 | const setInt64 = (addr, v) => {
68 | mem().setUint32(addr + 0, v, true);
69 | mem().setUint32(addr + 4, Math.floor(v / 4294967296), true);
70 | }
71 |
72 | const getInt64 = (addr) => {
73 | const low = mem().getUint32(addr + 0, true);
74 | const high = mem().getInt32(addr + 4, true);
75 | return low + high * 4294967296;
76 | }
77 |
78 | const loadValue = (addr) => {
79 | const f = mem().getFloat64(addr, true);
80 | if (f === 0) {
81 | return undefined;
82 | }
83 | if (!isNaN(f)) {
84 | return f;
85 | }
86 |
87 | const id = mem().getUint32(addr, true);
88 | return this._values[id];
89 | }
90 |
91 | const storeValue = (addr, v) => {
92 | const nanHead = 0x7FF80000;
93 |
94 | if (typeof v === "number") {
95 | if (isNaN(v)) {
96 | mem().setUint32(addr + 4, nanHead, true);
97 | mem().setUint32(addr, 0, true);
98 | return;
99 | }
100 | if (v === 0) {
101 | mem().setUint32(addr + 4, nanHead, true);
102 | mem().setUint32(addr, 1, true);
103 | return;
104 | }
105 | mem().setFloat64(addr, v, true);
106 | return;
107 | }
108 |
109 | switch (v) {
110 | case undefined:
111 | mem().setFloat64(addr, 0, true);
112 | return;
113 | case null:
114 | mem().setUint32(addr + 4, nanHead, true);
115 | mem().setUint32(addr, 2, true);
116 | return;
117 | case true:
118 | mem().setUint32(addr + 4, nanHead, true);
119 | mem().setUint32(addr, 3, true);
120 | return;
121 | case false:
122 | mem().setUint32(addr + 4, nanHead, true);
123 | mem().setUint32(addr, 4, true);
124 | return;
125 | }
126 |
127 | let ref = this._refs.get(v);
128 | if (ref === undefined) {
129 | ref = this._values.length;
130 | this._values.push(v);
131 | this._refs.set(v, ref);
132 | }
133 | let typeFlag = 0;
134 | switch (typeof v) {
135 | case "string":
136 | typeFlag = 1;
137 | break;
138 | case "symbol":
139 | typeFlag = 2;
140 | break;
141 | case "function":
142 | typeFlag = 3;
143 | break;
144 | }
145 | mem().setUint32(addr + 4, nanHead | typeFlag, true);
146 | mem().setUint32(addr, ref, true);
147 | }
148 |
149 | const loadSlice = (addr) => {
150 | const array = getInt64(addr + 0);
151 | const len = getInt64(addr + 8);
152 | return new Uint8Array(this._inst.exports.mem.buffer, array, len);
153 | }
154 |
155 | const loadSliceOfValues = (addr) => {
156 | const array = getInt64(addr + 0);
157 | const len = getInt64(addr + 8);
158 | const a = new Array(len);
159 | for (let i = 0; i < len; i++) {
160 | a[i] = loadValue(array + i * 8);
161 | }
162 | return a;
163 | }
164 |
165 | const loadString = (addr) => {
166 | const saddr = getInt64(addr + 0);
167 | const len = getInt64(addr + 8);
168 | return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
169 | }
170 |
171 | const timeOrigin = Date.now() - performance.now();
172 | this.importObject = {
173 | go: {
174 | // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
175 | // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
176 | // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
177 | // This changes the SP, thus we have to update the SP used by the imported function.
178 |
179 | // func wasmExit(code int32)
180 | "runtime.wasmExit": (sp) => {
181 | const code = mem().getInt32(sp + 8, true);
182 | this.exited = true;
183 | delete this._inst;
184 | delete this._values;
185 | delete this._refs;
186 | this.exit(code);
187 | },
188 |
189 | // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
190 | "runtime.wasmWrite": (sp) => {
191 | const fd = getInt64(sp + 8);
192 | const p = getInt64(sp + 16);
193 | const n = mem().getInt32(sp + 24, true);
194 | fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
195 | },
196 |
197 | // func nanotime() int64
198 | "runtime.nanotime": (sp) => {
199 | setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
200 | },
201 |
202 | // func walltime() (sec int64, nsec int32)
203 | "runtime.walltime": (sp) => {
204 | const msec = (new Date).getTime();
205 | setInt64(sp + 8, msec / 1000);
206 | mem().setInt32(sp + 16, (msec % 1000) * 1000000, true);
207 | },
208 |
209 | // func scheduleTimeoutEvent(delay int64) int32
210 | "runtime.scheduleTimeoutEvent": (sp) => {
211 | const id = this._nextCallbackTimeoutID;
212 | this._nextCallbackTimeoutID++;
213 | this._scheduledTimeouts.set(id, setTimeout(
214 | () => { this._resume(); },
215 | getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early
216 | ));
217 | mem().setInt32(sp + 16, id, true);
218 | },
219 |
220 | // func clearTimeoutEvent(id int32)
221 | "runtime.clearTimeoutEvent": (sp) => {
222 | const id = mem().getInt32(sp + 8, true);
223 | clearTimeout(this._scheduledTimeouts.get(id));
224 | this._scheduledTimeouts.delete(id);
225 | },
226 |
227 | // func getRandomData(r []byte)
228 | "runtime.getRandomData": (sp) => {
229 | crypto.getRandomValues(loadSlice(sp + 8));
230 | },
231 |
232 | // func stringVal(value string) ref
233 | "syscall/js.stringVal": (sp) => {
234 | storeValue(sp + 24, loadString(sp + 8));
235 | },
236 |
237 | // func valueGet(v ref, p string) ref
238 | "syscall/js.valueGet": (sp) => {
239 | const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
240 | sp = this._inst.exports.getsp(); // see comment above
241 | storeValue(sp + 32, result);
242 | },
243 |
244 | // func valueSet(v ref, p string, x ref)
245 | "syscall/js.valueSet": (sp) => {
246 | Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
247 | },
248 |
249 | // func valueIndex(v ref, i int) ref
250 | "syscall/js.valueIndex": (sp) => {
251 | storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
252 | },
253 |
254 | // valueSetIndex(v ref, i int, x ref)
255 | "syscall/js.valueSetIndex": (sp) => {
256 | Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
257 | },
258 |
259 | // func valueCall(v ref, m string, args []ref) (ref, bool)
260 | "syscall/js.valueCall": (sp) => {
261 | try {
262 | const v = loadValue(sp + 8);
263 | const m = Reflect.get(v, loadString(sp + 16));
264 | const args = loadSliceOfValues(sp + 32);
265 | const result = Reflect.apply(m, v, args);
266 | sp = this._inst.exports.getsp(); // see comment above
267 | storeValue(sp + 56, result);
268 | mem().setUint8(sp + 64, 1);
269 | } catch (err) {
270 | storeValue(sp + 56, err);
271 | mem().setUint8(sp + 64, 0);
272 | }
273 | },
274 |
275 | // func valueInvoke(v ref, args []ref) (ref, bool)
276 | "syscall/js.valueInvoke": (sp) => {
277 | try {
278 | const v = loadValue(sp + 8);
279 | const args = loadSliceOfValues(sp + 16);
280 | const result = Reflect.apply(v, undefined, args);
281 | sp = this._inst.exports.getsp(); // see comment above
282 | storeValue(sp + 40, result);
283 | mem().setUint8(sp + 48, 1);
284 | } catch (err) {
285 | storeValue(sp + 40, err);
286 | mem().setUint8(sp + 48, 0);
287 | }
288 | },
289 |
290 | // func valueNew(v ref, args []ref) (ref, bool)
291 | "syscall/js.valueNew": (sp) => {
292 | try {
293 | const v = loadValue(sp + 8);
294 | const args = loadSliceOfValues(sp + 16);
295 | const result = Reflect.construct(v, args);
296 | sp = this._inst.exports.getsp(); // see comment above
297 | storeValue(sp + 40, result);
298 | mem().setUint8(sp + 48, 1);
299 | } catch (err) {
300 | storeValue(sp + 40, err);
301 | mem().setUint8(sp + 48, 0);
302 | }
303 | },
304 |
305 | // func valueLength(v ref) int
306 | "syscall/js.valueLength": (sp) => {
307 | setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
308 | },
309 |
310 | // valuePrepareString(v ref) (ref, int)
311 | "syscall/js.valuePrepareString": (sp) => {
312 | const str = encoder.encode(String(loadValue(sp + 8)));
313 | storeValue(sp + 16, str);
314 | setInt64(sp + 24, str.length);
315 | },
316 |
317 | // valueLoadString(v ref, b []byte)
318 | "syscall/js.valueLoadString": (sp) => {
319 | const str = loadValue(sp + 8);
320 | loadSlice(sp + 16).set(str);
321 | },
322 |
323 | // func valueInstanceOf(v ref, t ref) bool
324 | "syscall/js.valueInstanceOf": (sp) => {
325 | mem().setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16));
326 | },
327 |
328 | "debug": (value) => {
329 | console.log(value);
330 | },
331 | }
332 | };
333 | }
334 |
335 | async run(instance) {
336 | this._inst = instance;
337 | this._values = [ // TODO: garbage collection
338 | NaN,
339 | 0,
340 | null,
341 | true,
342 | false,
343 | global,
344 | this._inst.exports.mem,
345 | this,
346 | ];
347 | this._refs = new Map();
348 | this.exited = false;
349 |
350 | const mem = new DataView(this._inst.exports.mem.buffer)
351 |
352 | // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
353 | let offset = 4096;
354 |
355 | const strPtr = (str) => {
356 | let ptr = offset;
357 | new Uint8Array(mem.buffer, offset, str.length + 1).set(encoder.encode(str + "\0"));
358 | offset += str.length + (8 - (str.length % 8));
359 | return ptr;
360 | };
361 |
362 | const argc = this.argv.length;
363 |
364 | const argvPtrs = [];
365 | this.argv.forEach((arg) => {
366 | argvPtrs.push(strPtr(arg));
367 | });
368 |
369 | const keys = Object.keys(this.env).sort();
370 | argvPtrs.push(keys.length);
371 | keys.forEach((key) => {
372 | argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
373 | });
374 |
375 | const argv = offset;
376 | argvPtrs.forEach((ptr) => {
377 | mem.setUint32(offset, ptr, true);
378 | mem.setUint32(offset + 4, 0, true);
379 | offset += 8;
380 | });
381 |
382 | this._inst.exports.run(argc, argv);
383 | if (this.exited) {
384 | this._resolveExitPromise();
385 | }
386 | await this._exitPromise;
387 | }
388 |
389 | _resume() {
390 | if (this.exited) {
391 | throw new Error("Go program has already exited");
392 | }
393 | this._inst.exports.resume();
394 | if (this.exited) {
395 | this._resolveExitPromise();
396 | }
397 | }
398 |
399 | _makeFuncWrapper(id) {
400 | const go = this;
401 | return function () {
402 | const event = { id: id, this: this, args: arguments };
403 | go._pendingEvent = event;
404 | go._resume();
405 | return event.result;
406 | };
407 | }
408 | }
409 | })();
410 |
411 | async function runApp(filePath) {
412 | if (!WebAssembly.instantiateStreaming) {
413 | // polyfill
414 | WebAssembly.instantiateStreaming = async (resp, importObject) => {
415 | const source = await (await resp).arrayBuffer();
416 | return await WebAssembly.instantiate(source, importObject);
417 | };
418 | }
419 | const go = new Go();
420 | const {
421 | module: mod,
422 | instance: inst
423 | } = await WebAssembly.instantiateStreaming(fetch(filePath), go.importObject);
424 | await go.run(inst);
425 | await WebAssembly.instantiate(mod, go.importObject); // reset instance
426 | }
427 |
--------------------------------------------------------------------------------
/index.wasm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/syumai/go-wasm-todo-list/43e73a7bfd4467de75dc7634f9613724602c2df6/index.wasm
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "syscall/js"
5 |
6 | "github.com/syumai/go-hyperscript/dom"
7 | h "github.com/syumai/go-hyperscript/hyperscript"
8 | "github.com/syumai/go-wasm-todo-list/components"
9 | "github.com/syumai/go-wasm-todo-list/store"
10 | )
11 |
12 | var (
13 | renderer = dom.NewRenderer()
14 | body = js.Global().Get("document").Get("body")
15 | state = store.NewState()
16 | )
17 |
18 | var (
19 | appendToDo = h.FuncOf(func(this h.Value, args []h.Value) interface{} {
20 | event := args[0]
21 | event.Call("preventDefault")
22 | state.AppendToDo(state.Title)
23 | state.SetTitle("")
24 | return nil
25 | })
26 |
27 | setTitle = h.FuncOf(func(this h.Value, args []h.Value) interface{} {
28 | event := args[0]
29 | event.Call("preventDefault")
30 | state.SetTitle(event.Get("target").Get("value").String())
31 | return nil
32 | })
33 | )
34 |
35 | func render() {
36 | app := h.H("div", nil,
37 | h.H("h2", nil, h.Text("ToDo List")),
38 | h.H(components.ToDo, h.Object{
39 | "toDos": state.ToDos,
40 | "title": state.Title,
41 | "setTitle": setTitle,
42 | "appendToDo": appendToDo,
43 | "updateToDo": func(id int, done bool) { state.UpdateToDo(id, done) },
44 | }),
45 | h.H("a", h.Object{"href": "https://github.com/syumai/go-wasm-todo-list/"},
46 | h.Text("Show the code on GitHub"),
47 | ),
48 | )
49 | renderer.Render(app, body)
50 | }
51 |
52 | func main() {
53 | state.Subscribe(render)
54 | render()
55 | select {}
56 | }
57 |
--------------------------------------------------------------------------------
/store/helpers.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | func SeparateToDos(toDos []*ToDo) (doingToDos, doneToDos []*ToDo) {
4 | for _, toDo := range toDos {
5 | if toDo.Done {
6 | doneToDos = append(doneToDos, toDo)
7 | } else {
8 | doingToDos = append(doingToDos, toDo)
9 | }
10 | }
11 | return
12 | }
13 |
--------------------------------------------------------------------------------
/store/mutations.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | func (s *State) SetTitle(title string) {
4 | defer s.onUpdated()
5 |
6 | s.Title = title
7 | }
8 |
9 | func (s *State) AppendToDo(title string) {
10 | defer s.onUpdated()
11 |
12 | s.LastID++
13 | s.ToDos = append(s.ToDos, &ToDo{
14 | Title: title,
15 | ID: s.LastID,
16 | })
17 | }
18 |
19 | func (s *State) UpdateToDo(id int, done bool) {
20 | defer s.onUpdated()
21 |
22 | var t *ToDo
23 | for i := 0; i < len(s.ToDos); i++ {
24 | p := s.ToDos[i]
25 | if p.ID == id {
26 | t = p
27 | break
28 | }
29 | }
30 | if t == nil {
31 | return
32 | }
33 | t.Done = done
34 | }
35 |
--------------------------------------------------------------------------------
/store/state.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | type State struct {
4 | LastID int
5 | Title string
6 | ToDos []*ToDo
7 | hooks []func()
8 | }
9 |
10 | func NewState() *State {
11 | return &State{}
12 | }
13 |
14 | func (s *State) Subscribe(f func()) {
15 | s.hooks = append(s.hooks, f)
16 | }
17 |
18 | func (s *State) onUpdated() {
19 | for _, hook := range s.hooks {
20 | hook()
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/store/todo.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | type ToDo struct {
4 | Title string
5 | Done bool
6 | ID int
7 | }
8 |
--------------------------------------------------------------------------------
/style/style.go:
--------------------------------------------------------------------------------
1 | package style
2 |
3 | type Prop [2]string
4 |
5 | func Style(styles ...Prop) string {
6 | if len(styles) < 2 {
7 | return ""
8 | }
9 | var styleStr string
10 | for _, style := range styles {
11 | styleStr += style[0] + ": " + style[1] + ";"
12 | }
13 | return styleStr
14 | }
15 |
--------------------------------------------------------------------------------
/style/todo.go:
--------------------------------------------------------------------------------
1 | package style
2 |
3 | var (
4 | ListContainerStyle = Style(
5 | Prop{"width", "100%"},
6 | Prop{"display", "flex"},
7 | Prop{"flex-wrap", "wrap"},
8 | )
9 |
10 | ListStyle = Style(
11 | Prop{"width", "50%"},
12 | Prop{"max-width", "250px"},
13 | )
14 | )
15 |
--------------------------------------------------------------------------------