curses

Pythonの標準ライブラリに含まれる低レベルなターミナル制御ライブラリ。Unix系システムでのTUIアプリケーション開発の基盤となります。

TUIStandard LibraryTerminalncursesLow-level

curses

cursesは、Pythonの標準ライブラリに含まれる低レベルなターミナル制御ライブラリです。Unix系システムのncursesライブラリをPythonから利用できるようにしたもので、多くのTUIライブラリの基盤技術として使用されています。直接的な画面制御と柔軟なカスタマイズが可能です。

主な特徴

標準ライブラリ

  • 追加インストール不要: Python標準ライブラリに含まれる
  • 安定性: 長年にわたる実績と安定した動作
  • 互換性: Unix系システムでの高い互換性

低レベル制御

  • 直接制御: 画面、カーソル、キーボードの直接制御
  • 高いパフォーマンス: 最適化された画面更新
  • 柔軟性: 細かな制御が可能

基盤技術

  • 他ライブラリの土台: 多くのTUIライブラリがcursesをベースに構築
  • 学習価値: TUIプログラミングの基礎を学べる
  • カスタマイズ: 独自のTUIライブラリ開発が可能

インストール

# 標準ライブラリのため、追加インストール不要
import curses

基本的な使用方法

初期化と基本構造

import curses

def main(stdscr):
    # 画面の初期化
    curses.curs_set(0)  # カーソルを非表示
    stdscr.nodelay(1)   # ノンブロッキングキー入力
    stdscr.timeout(100) # 100msタイムアウト
    
    # 色の初期化
    curses.start_color()
    curses.use_default_colors()
    curses.init_pair(1, curses.COLOR_RED, -1)
    curses.init_pair(2, curses.COLOR_GREEN, -1)
    curses.init_pair(3, curses.COLOR_BLUE, -1)
    
    height, width = stdscr.getmaxyx()
    
    while True:
        stdscr.clear()
        
        # テキストの表示
        stdscr.addstr(0, 0, "Hello, curses!")
        stdscr.addstr(1, 0, f"Terminal size: {width}x{height}")
        
        # 色付きテキスト
        stdscr.addstr(3, 0, "Red text", curses.color_pair(1))
        stdscr.addstr(4, 0, "Green text", curses.color_pair(2))
        stdscr.addstr(5, 0, "Blue text", curses.color_pair(3))
        
        # キー入力処理
        key = stdscr.getch()
        if key == ord('q'):
            break
        elif key != -1:
            stdscr.addstr(7, 0, f"Key pressed: {chr(key) if 32 <= key <= 126 else key}")
        
        stdscr.refresh()

# cursesアプリケーションの実行
curses.wrapper(main)

ウィンドウの作成と管理

import curses

def main(stdscr):
    curses.curs_set(0)
    curses.start_color()
    curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
    curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_RED)
    
    height, width = stdscr.getmaxyx()
    
    # メインウィンドウの作成
    main_win = curses.newwin(height-2, width-2, 1, 1)
    main_win.box()
    main_win.addstr(0, 2, " Main Window ")
    
    # サブウィンドウの作成
    sub_win = curses.newwin(10, 30, 5, 10)
    sub_win.bkgd(' ', curses.color_pair(1))
    sub_win.box()
    sub_win.addstr(1, 2, "Sub Window")
    sub_win.addstr(3, 2, "Press 'q' to quit")
    
    # ポップアップウィンドウ
    popup_win = curses.newwin(6, 25, 8, 40)
    popup_win.bkgd(' ', curses.color_pair(2))
    popup_win.box()
    popup_win.addstr(1, 2, "Popup Window")
    popup_win.addstr(3, 2, "Any key to close")
    
    # 初期描画
    stdscr.refresh()
    main_win.refresh()
    sub_win.refresh()
    
    show_popup = False
    
    while True:
        key = stdscr.getch()
        
        if key == ord('q'):
            break
        elif key == ord('p'):
            show_popup = not show_popup
        elif show_popup and key != -1:
            show_popup = False
        
        # ポップアップの表示/非表示
        if show_popup:
            popup_win.refresh()
        else:
            popup_win.clear()
            popup_win.refresh()
            # メインウィンドウの再描画
            main_win.refresh()
            sub_win.refresh()

curses.wrapper(main)

実践的な例

シンプルなテキストエディタ

import curses

class SimpleEditor:
    def __init__(self, stdscr):
        self.stdscr = stdscr
        self.lines = [""]
        self.cursor_y = 0
        self.cursor_x = 0
        self.scroll_y = 0
        
        curses.curs_set(1)  # カーソル表示
        curses.start_color()
        curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
        
        self.height, self.width = stdscr.getmaxyx()
        self.text_height = self.height - 2  # ステータスライン分を除く
    
    def draw(self):
        self.stdscr.clear()
        
        # ステータスライン
        status = f"Line: {self.cursor_y + 1}, Col: {self.cursor_x + 1} | Press Ctrl+Q to quit"
        self.stdscr.addstr(0, 0, status[:self.width-1], curses.color_pair(1))
        
        # テキスト表示
        for i in range(self.text_height):
            line_num = i + self.scroll_y
            if line_num < len(self.lines):
                line = self.lines[line_num]
                if len(line) <= self.width - 1:
                    self.stdscr.addstr(i + 1, 0, line)
                else:
                    self.stdscr.addstr(i + 1, 0, line[:self.width-1])
        
        # カーソル位置を設定
        screen_y = self.cursor_y - self.scroll_y + 1
        if 0 <= screen_y < self.height:
            self.stdscr.move(screen_y, min(self.cursor_x, self.width - 1))
        
        self.stdscr.refresh()
    
    def handle_key(self, key):
        if key == 17:  # Ctrl+Q
            return False
        elif key == curses.KEY_UP:
            if self.cursor_y > 0:
                self.cursor_y -= 1
                self.cursor_x = min(self.cursor_x, len(self.lines[self.cursor_y]))
                self.adjust_scroll()
        elif key == curses.KEY_DOWN:
            if self.cursor_y < len(self.lines) - 1:
                self.cursor_y += 1
                self.cursor_x = min(self.cursor_x, len(self.lines[self.cursor_y]))
                self.adjust_scroll()
        elif key == curses.KEY_LEFT:
            if self.cursor_x > 0:
                self.cursor_x -= 1
            elif self.cursor_y > 0:
                self.cursor_y -= 1
                self.cursor_x = len(self.lines[self.cursor_y])
                self.adjust_scroll()
        elif key == curses.KEY_RIGHT:
            if self.cursor_x < len(self.lines[self.cursor_y]):
                self.cursor_x += 1
            elif self.cursor_y < len(self.lines) - 1:
                self.cursor_y += 1
                self.cursor_x = 0
                self.adjust_scroll()
        elif key == curses.KEY_BACKSPACE or key == 127:
            if self.cursor_x > 0:
                line = self.lines[self.cursor_y]
                self.lines[self.cursor_y] = line[:self.cursor_x-1] + line[self.cursor_x:]
                self.cursor_x -= 1
            elif self.cursor_y > 0:
                # 前の行と結合
                prev_line = self.lines[self.cursor_y - 1]
                curr_line = self.lines[self.cursor_y]
                self.lines[self.cursor_y - 1] = prev_line + curr_line
                del self.lines[self.cursor_y]
                self.cursor_y -= 1
                self.cursor_x = len(prev_line)
                self.adjust_scroll()
        elif key == 10 or key == 13:  # Enter
            line = self.lines[self.cursor_y]
            self.lines[self.cursor_y] = line[:self.cursor_x]
            self.lines.insert(self.cursor_y + 1, line[self.cursor_x:])
            self.cursor_y += 1
            self.cursor_x = 0
            self.adjust_scroll()
        elif 32 <= key <= 126:  # 印刷可能文字
            line = self.lines[self.cursor_y]
            self.lines[self.cursor_y] = line[:self.cursor_x] + chr(key) + line[self.cursor_x:]
            self.cursor_x += 1
        
        return True
    
    def adjust_scroll(self):
        # スクロール調整
        if self.cursor_y < self.scroll_y:
            self.scroll_y = self.cursor_y
        elif self.cursor_y >= self.scroll_y + self.text_height:
            self.scroll_y = self.cursor_y - self.text_height + 1
    
    def run(self):
        while True:
            self.draw()
            key = self.stdscr.getch()
            if not self.handle_key(key):
                break

def main(stdscr):
    editor = SimpleEditor(stdscr)
    editor.run()

curses.wrapper(main)

メニューシステム

import curses

class Menu:
    def __init__(self, stdscr, items, title="Menu"):
        self.stdscr = stdscr
        self.items = items
        self.title = title
        self.selected = 0
        
        curses.curs_set(0)
        curses.start_color()
        curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)  # 選択項目
        curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLUE)   # タイトル
        
        self.height, self.width = stdscr.getmaxyx()
        
        # メニューウィンドウのサイズ計算
        self.menu_height = len(items) + 4
        self.menu_width = max(len(item) for item in items) + 6
        self.menu_width = max(self.menu_width, len(title) + 4)
        
        # 中央配置
        self.start_y = (self.height - self.menu_height) // 2
        self.start_x = (self.width - self.menu_width) // 2
        
        self.win = curses.newwin(self.menu_height, self.menu_width, 
                                self.start_y, self.start_x)
    
    def draw(self):
        self.win.clear()
        self.win.box()
        
        # タイトル
        title_x = (self.menu_width - len(self.title)) // 2
        self.win.addstr(1, title_x, self.title, curses.color_pair(2))
        
        # メニュー項目
        for i, item in enumerate(self.items):
            y = i + 3
            x = 2
            
            if i == self.selected:
                self.win.addstr(y, x, f"> {item}", curses.color_pair(1))
            else:
                self.win.addstr(y, x, f"  {item}")
        
        self.win.refresh()
    
    def handle_key(self, key):
        if key == curses.KEY_UP:
            self.selected = (self.selected - 1) % len(self.items)
        elif key == curses.KEY_DOWN:
            self.selected = (self.selected + 1) % len(self.items)
        elif key == 10 or key == 13:  # Enter
            return self.selected
        elif key == 27:  # ESC
            return -1
        
        return None
    
    def run(self):
        while True:
            self.draw()
            key = self.stdscr.getch()
            result = self.handle_key(key)
            
            if result is not None:
                return result

def main(stdscr):
    # メインメニュー
    main_items = ["New File", "Open File", "Settings", "About", "Exit"]
    
    while True:
        menu = Menu(stdscr, main_items, "Main Menu")
        choice = menu.run()
        
        if choice == 0:  # New File
            stdscr.clear()
            stdscr.addstr(5, 5, "New File selected. Press any key to continue...")
            stdscr.refresh()
            stdscr.getch()
        elif choice == 1:  # Open File
            stdscr.clear()
            stdscr.addstr(5, 5, "Open File selected. Press any key to continue...")
            stdscr.refresh()
            stdscr.getch()
        elif choice == 2:  # Settings
            settings_items = ["Theme", "Fonts", "Shortcuts", "Back"]
            settings_menu = Menu(stdscr, settings_items, "Settings")
            settings_choice = settings_menu.run()
            
            if settings_choice != -1 and settings_choice != 3:
                stdscr.clear()
                stdscr.addstr(5, 5, f"Settings: {settings_items[settings_choice]} selected")
                stdscr.addstr(6, 5, "Press any key to continue...")
                stdscr.refresh()
                stdscr.getch()
        elif choice == 3:  # About
            stdscr.clear()
            stdscr.addstr(5, 5, "Simple curses application")
            stdscr.addstr(6, 5, "Press any key to continue...")
            stdscr.refresh()
            stdscr.getch()
        elif choice == 4 or choice == -1:  # Exit
            break

curses.wrapper(main)

他のライブラリとの比較

特徴cursesBlessedRichTextual
インストール標準pippippip
複雑さ
制御レベル低レベル中レベル高レベル高レベル
パフォーマンス最高
学習コスト

使用事例

  • システムツール: 低レベルなシステム管理ツール
  • 高性能アプリ: パフォーマンスが重要なアプリケーション
  • 学習目的: TUIプログラミングの基礎学習
  • ライブラリ開発: 独自TUIライブラリの基盤

注意事項とベストプラクティス

プラットフォーム対応

import curses
import sys

def main():
    try:
        # Windowsでは動作しない場合がある
        curses.wrapper(run_app)
    except ImportError:
        print("curses is not available on this platform")
        sys.exit(1)

エラーハンドリング

import curses

def safe_addstr(win, y, x, text, attr=0):
    """安全な文字列描画"""
    try:
        win.addstr(y, x, text, attr)
    except curses.error:
        # 画面境界外への描画エラーを無視
        pass

コミュニティとサポート

  • 公式ドキュメント: Python公式ドキュメントで詳細に解説
  • 安定性: 長年にわたる実績
  • 互換性: Unix系システムでの高い互換性
  • 学習リソース: 豊富なチュートリアルと例題

cursesは、TUIアプリケーション開発の基盤技術として、高いパフォーマンスと柔軟性を提供する重要なライブラリです。