[4] Tauri로 Text Editor 만들기

2025. 5. 7. 11:20Tauri/Tutorial

Tauri Text Editor

tauri text editor
tauri text editor

기능

  • 파일 열기: 파일 내용 확인
  • 파일 저장

Frontend

전체 코드

src/index.html

<html>
  <head>

  </head>
  <body onload="init()">
    <div>
      <button onclick="openDialog()">Open File</button>
      <button onclick="saveDialog()">Save File</button>
    </div>
  </body>

  <textarea id="contents"></textarea>

  <script>
    const invoke = window.__TAURI__.core.invoke;
    const listen = window.__TAURI__.event.listen;

    async function openDialog() {
      await invoke("open_file");
    }

    async function saveDialog() {
      let contents = document.querySelector("#contents").value;

      await invoke("save_file", { contents : contents});
    }

    function init() {
      listen("contents", data => {
        document.querySelector("#contents").value = data.payload;
      });
    }


  </script>
</html>

코드 분석

const invoke = window.__TAURI__.core.invoke;
const listen = window.__TAURI__.event.listen;

core.invoke 함수는 저번 포스팅에서 설명했으므로 패스
event 함수는 백엔드와 프런트엔드를 연결하여 emit 하거나 listen 할 수 있게 한다.

그중 listen 함수를 호출하여 백엔드의 이벤트를 수신할 수 있게 한다.

function init() {
      listen("contents", data => {
        document.querySelector("#contents").value = data.payload;
});

수신한 데이터를 #contents 필드에 넣어주는 초기화 함수이다.
<body onload="init()">으로 호출한다.

함수의 내용은 백엔드에서 emit한 "contents" 변수의 내용 data를 #contents 필드에 넣는다.

=> file open 기능에 사용된다.

payload를 알아보기 위해 Tauri의 리스너 콜백 데이터 구조를 살펴보겠다.

{
  event: "contents",   // 이벤트 이름
  id:     42,          // 내부 식별자(숫자)
  payload: ...         // ★ 보낸 쪽이 실어 준 실제 데이터
}

data.payload를 통해 이벤트의 실제 데이터인 payload에 접근하는 구조이다.

마지막으로 saveDialog() 함수를 살펴보겠다.

async function saveDialog() {
      let contents = document.querySelector("#contents").value;

      await invoke("save_file", { contents : contents});
    }

간단한 구조!
백엔드의 save_file 함수에 invoke 하여 contents를 입력으로 줄 것이다.


Backend

전체 코드

src-tauri/src/lib.rs

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use tauri_plugin_dialog::DialogExt;
use tauri::{AppHandle, Emitter};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .invoke_handler(tauri::generate_handler![save_file, open_file])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

#[tauri::command]
fn open_file( app : AppHandle ){

    std::thread::spawn(move || {
        let file_path = app.dialog().file().blocking_pick_file().unwrap();

        let file = file_path.to_string();

        let contents = std::fs::read_to_string(file).unwrap();

        app.emit("contents", contents).unwrap();
    });
}

#[tauri::command]
fn save_file( app : AppHandle, contents : String ){

    std::thread::spawn(move || {
        let file_path = app.dialog().file().blocking_save_file().unwrap();

        let file = file_path.to_string();

        std::fs::write(file, contents).unwrap();

        app.emit("save_state", "saved").unwrap();
    });
}

코드 분석

use tauri_plugin_dialog::DialogExt;
use tauri::{AppHandle, Emitter};

tauri_plugin_dialog는 파일 열기 / 저장 등을 담당한다.
tauri_plugin_dialog 크레이트를 설치해야 한다. 설치 명령어는 아래와 같다.

cargo add tauri_plugin_dialog

AppHandle : 윈도우 열기·닫기, 플러그인 접근, 전역 상태 관리 등 백엔드 쪽에서 앱을 제어할 때 사용
Emitter : Tauri 내부 이벤트 버스에 이벤트를 발행(emit) 할 수 있는 트레이트

이제 open_file, save_file 함수를 차례대로 살펴보자.

#[tauri::command]
fn open_file( app : AppHandle ){

    std::thread::spawn(move || {
        let file_path = app.dialog().file().blocking_pick_file().unwrap();

        let file = file_path.to_string();

        let contents = std::fs::read_to_string(file).unwrap();

        app.emit("contents", contents).unwrap();
    });
}

함수의 input은 AppHandle

std::thread::spawn(move || {...});

Rust의 강력함을 나타내는 thread이다.
새 thread를 만들어서 안쪽 클로저를 실행하라 라는 뜻이다.
move 키워드를 통해 외부 변수의 소유권을 클로저로 이동시킨다.

이를 통해 메인 thread와의 블로킹을 방지하고, 소유권을 확보하여 앱의 안정성을 높인다.
소유권 개념은... 나는 아직 좀 어렵다. 알기야 안다만...

클로저를 보자.

let file_path = app.dialog().file().blocking_pick_file().unwrap();

let file = file_path.to_string();

let contents = std::fs::read_to_string(file).unwrap();

app.emit("contents", contents).unwrap();

dialog 함수에서 file을 선택할 수 있게 "파일 선택 대화 상자"를 띄우고,
path를 문자열로 변환하여 file 변수에 저장한다.
이 file의 텍스트를 읽어서 contents에 저장한다.
이후 "contents" 이벤트를 emit 한다. payload는 contents 변수의 내용.

즉, 파일을 선택하면 그 내용물을 이벤트로 emit 한다.
frontend에서 본 listen 함수를 통해 프론트와 백엔드의 데이터 통신을 연결한다!

#[tauri::command]
fn save_file( app : AppHandle, contents : String ){

    std::thread::spawn(move || {
        let file_path = app.dialog().file().blocking_save_file().unwrap();

        let file = file_path.to_string();

        std::fs::write(file, contents).unwrap();

        app.emit("save_state", "saved").unwrap();
    });
}

save_file은 프론트에 보낼 정보가 없다. 다만 잘 저장되었다는 신호를 emit 하게 만들었다.

달라진 건 read와 write의 차이, blocking_save_file() 함수를 사용한다는 차이이다.